Skip to content

feat(mcp): keep project MCP configs with resolved tokens out of git (#882) - #886

Merged
jeff-r2026 merged 92 commits into
Tencent:mainfrom
SaulMoro:feat/882-mcp-git-exclude
Sep 30, 2026
Merged

jeff-r2026 merged 92 commits into
Tencent:mainfrom
SaulMoro:feat/882-mcp-git-exclude

Conversation

@SaulMoro

@SaulMoro SaulMoro commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A project MCP config that holds a resolved token is kept out of git through the clone's own .git/info/exclude. Nothing committed changes.

Most of this landed with #880: it was stacked on 2033b5be here, and its squash (7c929a99) carries the exclude block, uninstall, doctor's check and managed-mcp-files.json. See #880 for that design. Against main, this PR is the follow-up fixes after 2033b5be. They close three gaps:

 local agent (HTTP-backed team), install_mcp into a project config
+  server carries a credential (header, env value, argument, any URL, command line with arguments)
+    → list the file in .git/info/exclude before writing; tracked or unlistable → nothing written,
+      install fails with the reason ("install the MCP server again")
+  new install: Git preflight → provisional ownership → exclusion + file record → MCP write
+    initial ownership write fails → MCP config and exclusion unchanged
+  existing JSON update: retain prior ownership → MCP write → completed ownership
+    config write fails → prior record unchanged; later manifest failure → restore prior config
+  uninstall: remove the entry → drop ownership; read/write failure retains ownership
+    later manifest failure → restore the entry
+  failed reconcile before ownership is saved → restore every written config, once per shared file
+    failed restoration → report both failures; keep files with credentials excluded
+  next sync or pull in the workspace, also after an uninstall_teamai that failed or kept shared files
+    → list a file an older local agent wrote a credential into, and record it
+  a record's resolved: false counts only while the entry on disk has its hash (a failed write leaves the old one)
+  also read CodeBuddy's pre-57636a27 default .codebuddy/mcp.json (no teamai.yaml history to name it)
+  each entry judged on its own: a Copilot bare entry apart from the one of its name under mcpServers
 doctor
+  HTTP-backed team → check those configs too (✖ while one would commit)
 Copilot project config that another tool wrote mcpServers into
+  read the bare top-level servers beside mcpServers (protection, cleanup, doctor)
+  Copilot writes a server again under mcpServers → remove teamai's own bare copy (completed bare write + hash match)
+  a bare ownership record never authorizes updating/removing a member's mcpServers entry of the same name
+  an unmarked Copilot record needs a matching keyed hash, without an identical bare match
+  completed keyed writes persist bare: false; a failed placement write remains unproven
+  a moved Copilot's old file: a bare server counts as owned only by a Copilot record, never by a name
+    another tool owns under mcpServers
 tool the team moved, its old file now mapped by another tool
+  judged by the records of the tools that read the same key; a lost record's rebuild claims by key
src/local-agent.ts       install_mcp exclusion before the write, sync-time listing
src/mcp-reconcile.ts     Copilot bare servers beside mcpServers, bare-copy removal, key-scoped claims
src/mcp-git-exclude.ts   carriesLocalAgentCredential; callers name their own retry in the fix
src/doctor-delivery.ts   HTTP-team configs, judged by the local agent's records

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature causing existing behavior to change)
  • Documentation only
  • Refactor / internal cleanup

Test Plan

  • npx tsc --noEmit passes
  • npm run lint passes
  • npx vitest run passes (352 files, 6145 passed, 1 skipped at d548eaa2; CLAUDE_CONFIG_DIR unset, see fix(tests): prevent model tests from overwriting Claude config (P1) #890)
  • Added/updated tests for the change. All four regressions from the previous review fail against 9c3333d9 and pass after the fix.
  • npm run test:e2e: 391 passed, 26 skipped
local agent   header / env value install, git checkout → listed before the write            was: written, committable
              tracked config → withheld, reason named, file and records untouched           was: written
              bare stdio command → no line                                                  guard
              argument, any URL, OpenCode's command array → a credential                    was: only headers and env
              older install's credential file → listed on the next sync, no command needed  was: never listed
              sync with a failed uninstall_teamai → still listed                            was: skipped
              sync cannot list the file → says the next session tries again                 was: said to pull
              install of a bare command over an older credential entry, write fails → the
              next sync still lists the file                                                was: trusted resolved: false, committable
              credential at CodeBuddy's former .codebuddy/mcp.json → listed on sync; doctor ✖  was: never visited
              Copilot bare entry with a credential beside a credential-free mcpServers entry of
              its name → listed                                                             was: hidden by the merge
pull          HTTP team, older local agent's credential file → listed, recorded; dry run writes nothing   was: never listed
doctor        HTTP team: ✔ once ignored; ✖ for a listed file with no record, or a credential server's
              record lost while another's remains; nothing for a server without credentials was: skipped
Copilot bare  line kept while the bare entry holds the value beside Claude's mcpServers     was: hidden
              written again under mcpServers → bare copy removed                            was: stale token left beside it
              a member's own bare server of the same name → never removed                   was: deleted
              stale bare copy that differs from mcpServers' entry → line kept               guard
              team drops it → bare entry removed, Claude's mcpServers left                  was: left
              local agent: install again │ uninstall_mcp after another tool wrote mcpServers → bare entry replaced │ removed   was: left
moved + key   the tool mapping a moved tool's file today never claims its stale server under
              another key; doctor ✖ for it                                                  was: claimed, released
moved Copilot its bare stale server beside Claude's mcpServers entry of the same name → pull
              lists the file; doctor ✖                                                      was: claimed by name, released

Real CLI after the merge (npm run build, temporary HOME, CLAUDE_CONFIG_DIR unset, project scope, toolPaths puts Copilot and Claude on .mcp.json, jira for Copilot with Bearer ${JIRA_TOKEN}, open for Claude):

1. empty .mcp.json, pull           .mcp.json: { jira }  (bare, token)
2. team adds open, pull            .mcp.json: { mcpServers: { open, jira } }  bare jira gone, one copy of the token
3. jira made a literal, token unset, pull   token count 0
   .git/info/exclude               [teamai:mcp-exclude:start] /.mcp.json [end]; git status: .mcp.json not listed
   doctor                          ✔ Project MCP configs with resolved values are kept out of git

Real CLI, a moved Copilot (same setup, pre-fix build 7ffd5593 against 17b1a59d): Copilot on .mcp.json writes a bare jira with the token; the team moves Copilot to .github/mcp.json and gives jira to Claude as a literal; token unset, exclude emptied, pull.

                     7ffd5593                        17b1a59d
.mcp.json            bare jira (token) + mcpServers.jira (literal)
.git/info/exclude    (none)                          /.mcp.json
git status           ?? .mcp.json  ← token           (not listed)
doctor               no check                        ✔ … kept out of git

A second run of the base flow (Claude, project scope) also passed: pull, a second pull, doctor ✔, then uninstall --force removed the token and the block. Real CLI, local agent (hook-dispatch session-start against a local fake HTTP backend; install_mcp with a Bearer header, then the files left as an agent from before this PR left them: exclude emptied, no managed-mcp-files.json, no resolved note; one more sync). Pre-fix build 17b1a59d against 9c3333d9:

                                               17b1a59d                          9c3333d9
CodeBuddy, token at .codebuddy/mcp.json        exclude: none, ?? .codebuddy/…    /.codebuddy/mcp.json, not listed
Copilot, bare token + credential-free
  mcpServers entry of its name, .github/…      exclude: none, ?? .github/…       /.github/mcp.json, not listed

The local agent's failed-write case (a read-only directory) is covered by its unit test only.

Review verification at 4e71c7a6

 Copilot bare-copy cleanup
-  ownership name + content hash
+  ownership name + completed bare write + content hash
 HTTP install_mcp
-  exclusion + file record → ownership manifest → MCP config
+  Git preflight → ownership manifest → exclusion + file record → MCP config
+  record bare placement only after the MCP write succeeds
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 260 passed. Covers an identical member entry through pull/removal and install/update/uninstall, legacy records without placement evidence, and a failed ownership write leaving the user config visible to Git.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Full npx vitest run: 352 files, 6087 passed, 1 skipped. The first run alongside other checks had six failures in push-env.test.ts; its isolated run passed all 19, as did the same file on 9c3333d9. Repeating the full suite without concurrent checks passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record above remains from 9c3333d9; the full provider/agent suite was not rerun for this follow-up.
  • Real CLI after the build, using hook-dispatch session-start --tool copilot against a fixture HTTP backend: install, update and uninstall preserve an identical member-owned bare jira; a directory at managed-mcp.json makes install fail, while the config and exclusion stay unchanged, no sidecar appears, and git status still lists ?? .github/mcp.json. Harness: /tmp/882-r6-cli.mjs.

Review verification at 1a4ddfa1

 TeamAI owns bare jira; member adds mcpServers.jira
-  bare record's name authorizes changing both placements
+  update skips the unmanaged keyed collision and retains bare ownership
+  remove/uninstall deletes only the matching owned bare copy
+  missing-secret retention keeps the bare record without claiming the keyed entry
  • Five regressions fail before this fix at 4e71c7a6: pull update, dropping a server, removeAll, local-agent install/update, and local-agent uninstall. A sixth guard covers missing-secret retention. All six pass after the fix, including a repeated pull after a skipped update.
  • Full npx vitest run: 352 files, 6093 passed, 1 skipped.
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 266 passed.
  • npm run build, npx tsc --noEmit, npm run lint: passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • Real CLI after the build, hook-dispatch session-start --tool copilot against a fixture HTTP backend: initial bare TeamAI install succeeds; the member then adds a same-named keyed server; two update commands fail with not managed by teamai and preserve both entries; uninstall succeeds and removes only TeamAI's bare copy. Harness: /tmp/882-r7-cli.mjs.
  • git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe. The existing branch commits all belong to [feat] keep project MCP configs with resolved tokens out of git #882.

Review verification at c0748a9b

 Copilot project ownership
-  missing bare: true → keyed ownership by name
+  bare: true  → bare placement
+  bare: false → keyed placement
+  unmarked    → matching entry hash, with no equal bare match for a keyed entry
 local-agent install
+  preserve a prior proven placement while updating that same placement
+  record either placement after the config write succeeds
+  failure of the placement-record write leaves unknown placement
  • 13 regressions fail against 1a4ddfa1 and pass after the fix: legacy bare records and a failed post-write manifest update, each with distinct or identical keyed member content, exercised through pull/update/remove and local-agent install/uninstall; plus compatibility for a legacy keyed record with a matching hash.
  • Full npx vitest run: 352 files, 6106 passed, 1 skipped.
  • npx vitest run src/__tests__/mcp-reconcile.test.ts src/__tests__/local-agent-mcp.test.ts: 279 passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Real CLI after the build, hook-dispatch session-start --tool copilot against a fixture HTTP backend: a legacy unmarked bare record and an injected failure on the second atomic ownership-manifest rename both reject repeated updates and preserve the member's keyed entry on uninstall. The failure case uses identical bare/keyed content. A genuine legacy keyed entry with a unique matching hash still updates, records bare: false, and uninstalls. Harness: /tmp/882-r8-cli.mjs, fault injection: /tmp/882-r8-fault.mjs.
  • git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe. Every branch commit belongs to [feat] keep project MCP configs with resolved tokens out of git #882.

Latest review verification at d548eaa2

 existing JSON local-agent update
-  replace ownership -> write config
+  keep prior ownership -> write config -> save completed ownership
+    config write failure   -> prior record stays valid for retry/removal
+    manifest write failure -> restore prior config
 uninstall_mcp
-  drop ownership -> remove entry; an unreadable JSON config reports success
+  read/remove entry -> drop ownership; read/write failures keep ownership
+    manifest write failure -> restore entry
 MCP reconcile
+  snapshot each config once before any tool writes it
+  failure before ownership commit -> restore all written configs
+  retain newly added file records only after ownership is committed
 Codex config reads
-  read error -> absent file
+  propagate read error; preserve config and ownership
  • 39 additional regression cases cover proven bare/keyed and legacy records, bare-to-keyed migration, failed config/manifest writes, retry and uninstall, invalid JSON, Codex read failures, a second tool failing on a shared config, and restoration failures. Before repair, fault injection reproduced 25 local-agent failures, 5 reconcile failures and 2 Codex read failures.
  • Full npx vitest run: 352 files, 6145 passed, 1 skipped. MCP targeted suite: 420 passed; final local-agent suite after snapshot simplification: 76 passed.
  • npx tsc --noEmit, npm run lint, npm run build: passed.
  • Built-CLI e2e for copilot-mcp.test.ts and mcp-uninstall.test.ts: 2 passed. The earlier full e2e record remains from 9c3333d9.
  • Real CLI after the final build: hook-dispatch session-start --tool copilot, isolated HOME/Git workspace, fixture HTTP backend. Tested a legacy bare update, a proven bare-to-keyed update and uninstall. For each, injected an atomic config-rename failure, then a post-config ownership-manifest rename failure. Both failures reported failed, preserved the exact prior config/ownership, and left the credential file absent from Git status. Retry and subsequent uninstall succeeded; the old token was removed and the member's keyed server survived. Harness: /tmp/882-r9-cli.mjs; rename-fault preload: /tmp/882-r9-fault.mjs.
  • Reviewed the accumulated branch diff and listed readers/writers of MCP configs, ownership, resolved-file records and Git exclusions. git merge-tree --write-tree HEAD origin/main: no conflicts with 128844fe; no unrelated branch commits.
  • Recovery is covered for caught filesystem failures. If restoration itself fails, the command names both causes and requires repair of config/ownership; the remaining credential stays excluded. This change does not add a multi-file crash-recovery protocol.

Related Issues

Closes #882. Found while implementing #879 / #880.

Notes for Reviewers

Door: two-way. Only writes a marked block in a local, uncommitted file; uninstall removes it.

Blast Radius: MCP configs. HTTP-backed teams using local-agent install/uninstall, JSON/TOML reconcile failure recovery, and Copilot project configs shared with another tool.

  • Design: any credential counts for the local agent. Its payload carries literal values, not ${VAR}, so there is no definition to judge against. Only a bare stdio command is treated as credential-free.
  • Design: remove only what teamai wrote. A bare Copilot copy goes only when its record proves a completed bare write and its hash matches. Identical content in a member-owned bare entry proves no ownership. Old records without placement evidence leave that bare entry alone; an unchanged pull does not infer placement. A bare record cannot claim the keyed entry. A skipped update retains the bare ownership record so later cleanup still reaches the owned copy. Copilot records distinguish completed bare writes (bare: true), completed keyed writes (bare: false), and unknown placement. An unknown record needs a matching hash in the keyed entry and no equal hash in the bare entry; an identical duplicate is ambiguous and left alone.
  • Merge: the merge with main used 2033b5be as its base. One real conflict came out of it, in applyJsonTarget: feat(env): team-declared secrets with member-local values (#875) #880's holdsResolvedValue and this branch's bare-copy removal are both kept.

Known limits

  • For an HTTP-backed team, only teamai uninstall takes the line out: no pull or teamai mcp remove releases it. An older install's file is listed only in the current workspace; other workspaces are covered at their next session.
  • In a Copilot project config that also holds mcpServers, every other top-level object reads as a bare server. An object-valued setting there reads as a server no record claims, which keeps the line.

…encent#882)

A project-scope MCP config that carries a resolved ${VAR} sat untracked
and unignored in the business repo, one `git add -A` from committing the
token. After the reconcile writes such a file and git would track it,
teamai lists its path in the clone's .git/info/exclude inside a marked
block (resolved via `git rev-parse --git-path`, so linked worktrees and
submodules work). The committed .gitignore is never touched; an ignored
path or a config with no resolved value adds nothing; dry runs write
nothing. Project-scope uninstall removes only teamai's block, and doctor
reports such a file git would still commit.

The hook sits after the appliers in reconcileMcpForConfig, outside
desiredMcpForTarget/applyJson/applyCodex, so it merges cleanly with Tencent#880.
…Tencent#882)

The plan now records whether the project's .git/info/exclude holds
teamai's MCP config block (gitExcludeBlock). It counts toward
isPlanEmpty, is listed in the summary and dry run, and gates the
removal, so a plan whose only teamai leftover is the block removes it
instead of reporting "Nothing to uninstall".
@jeff-r2026 jeff-r2026 self-assigned this Sep 28, 2026
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/uninstall.ts:1251 removes the ignore block even when MCP entries were not removed. For example, applyJson skips an invalid .mcp.json without throwing; uninstall then exposes the still-present plaintext token to git add -A. Remove exclusions only after confirming every relevant config was cleaned successfully.
  • [P2 non-blocking] src/doctor-delivery.ts:526 infers a resolved secret from server names alone. A user-owned server colliding with a team server name is skipped by reconciliation, but doctor incorrectly claims it contains TeamAI-resolved plaintext and recommends teamai pull, which cannot fix the warning. Compare the installed entry with the desired entry or ownership manifest.
  • [P3 nit] src/mcp-git-exclude.ts:84 appends a new block when an existing start marker has lost its end marker. A later uninstall pairs the original start with the newly appended end and deletes any user exclusions between them, contrary to the damaged-block safeguard.
  • [P3 nit] src/uninstall.ts:1252 only removes the block from the project root repository, although excludeFromGit explicitly supports MCP paths inside submodules/nested repositories. Such configurations leave TeamAI’s block behind after uninstall.

The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…cent#882)

- uninstall keeps a repository's .git/info/exclude block while a config in
  it could not be parsed and still holds teamai servers, and warns
- uninstall finds and removes the block in nested repositories holding an
  MCP config, across every worktree
- the block opens at the last start marker, so an orphaned start never
  pairs with a later block's end and takes the member's lines
- doctor counts only servers the ownership manifest records, not a member's
  own server under a team name
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/uninstall.ts:1270 treats the absence of leftInPlace as proof that every protected config was cleaned. If managed-mcp.json is missing or unreadable, reconcileMcpForConfig returns early with no ownership records, leaving a resolved token in .mcp.json; uninstall then removes the exclude block, exposing that token to git add -A. Keep the block unless every protected path was positively inspected and cleaned.

The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…en clean (Tencent#882)

A missing or unreadable managed-mcp.json made the MCP cleanup return early
without reporting anything, so uninstall removed the block while .mcp.json
still held the resolved token.

Uninstall now inspects every path the block protects after the cleanup. The
block goes only when each one is missing, or parses and holds none of the
team's servers that need a resolved ${VAR}. Anything it cannot check keeps
the block, with a warning naming the file. This replaces the leftInPlace
report from the reconcile, which the check subsumes.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:568 skips excluded/disabled tools before applying the new Git protection. A project MCP file containing a plaintext token from an earlier pull remains on disk after its tool is disabled, but subsequent pulls never add it to .git/info/exclude; src/doctor-delivery.ts:527 skips the same target, so doctor also reports nothing. git add -A can commit the token. Existing owned entries must still be protected and diagnosed even when delivery is disabled.
  • [P2 non-blocking] src/mcp-git-exclude.ts:61 treats every git check-ignore failure as “Git would not track this file.” In a valid repository where Git returns 128—for example due to unsafe-repository configuration—the plaintext config is neither excluded nor warned about. Preserve an error state and warn rather than silently treating it as safe.
  • [P3 nit] src/uninstall.ts:539 proves cleanliness using only the team’s current MCP definitions. If a server was removed from mcp.yaml and its ownership manifest was also lost, its old plaintext entry is considered clean and the exclusion block is removed. This requires two failures, but exposes the remaining value.

The four earlier findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

…encent#882)

Pull, doctor and uninstall each skipped a case they had not inspected and
treated it as safe. Now:

- pull lists a config in .git/info/exclude whether or not it delivered to
  it this run: a disabled or undetected tool's file, a team with automatic
  delivery off, an unreadable mcp.yaml (any teamai entry counts), a failed
  write to another tool's config, and a lost ownership manifest (the
  resolved value found in the file)
- doctor checks the same files, including one that does not parse, and
  counts a git error as a failure
- git check-ignore failing inside a repository is no longer read as "not
  tracked": the path is excluded anyway, or teamai warns with git's error
- uninstall also keeps the block while a file contains the value (8+
  characters, not a path or the login name) of a variable still set in the
  environment, which finds a server since dropped from mcp.yaml
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:549 only recognizes owned entries whose server still exists in the current teamDefs. Concrete failure: an older pull writes a resolved jira token without an exclude block, the team later removes jira from mcp.yaml, and cleanup is skipped because .mcp.json is invalid or the tool is disabled. The token remains, but the protection pass adds no exclusion, so git add -A can commit it. src/doctor-delivery.ts:537 has the same false negative.
  • [P2 non-blocking] src/doctor-delivery.ts:519 returns without checking Git whenever the team MCP definitions cannot be resolved. With malformed mcp.yaml, a manifest-owned plaintext config that is tracked or no longer excluded receives only the generic parsing failure; doctor omits the specific credential-exposure warning even though pull conservatively treats that owned entry as sensitive.

The previously reported findings are resolved in the current diff. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…encent#882)

Pull, doctor and uninstall still decided "clean" from the current team
config in places. Now one function, resolvedValueEvidence, decides for all
three:

- a teamai-owned entry still in the file counts when its server has left
  mcp.yaml, as well as when it needs a resolved ${VAR} or mcp.yaml cannot
  be read (doctor no longer skips that case)
- targets include the built-in location of a tool the team dropped from
  toolPaths or moved
- doctor names a file two tools share once
- exclude updates take the existing acquireLock helper, re-read the file
  and write it atomically, so concurrent commands keep each other's paths
- uninstall inspects every worktree of each repository owning a block,
  including a nested repository's linked worktrees, and applies the
  manifest rule per worktree
- the kept-block warning names each file and why, such as the variable
  whose value matched
@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/uninstall.ts:553 cannot recognize configs in linked worktrees of a nested repository. findMcpGitExcludes expands the nested repository’s patterns across all its worktrees, but targets only covers worktrees of the outer project repository. Consequently, an existing but clean MCP config in a nested repo’s linked worktree is classified as “no tool teamai knows reads it,” so uninstall permanently retains an otherwise removable exclude block.
  • [P3 nit] src/mcp-git-exclude.ts:173 abandons serialization after 2.5 seconds and performs the read-modify-write without owning the lock. If another process remains paused or its filesystem operation exceeds that timeout, concurrent additions/removal can overwrite each other and lose a protected path. Waiting longer or failing safely would preserve the claimed locking guarantee.

The previously reported findings are resolved in the current diff. The PR description includes sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 28, 2026
…esolved tokens out of git

# Conflicts:
#	src/doctor-delivery.ts
#	src/mcp-reconcile.ts
…s its lock (Tencent#882)

After the 2.5 s wait for the exclude file's lock, updateExclude wrote without
it, so two writers could drop each other's pattern and leave a plaintext MCP
config committable. It now writes nothing and reports 'locked': pull warns that
the file is not excluded yet and to run `teamai pull` again (doctor's exclude
check keeps reporting it meanwhile), and uninstall keeps the block and warns.
… free of teamai's servers (Tencent#882)

Uninstall judged a protected file clean from the current mcp.yaml, manifest
and resolvable values, so with the manifest lost, the server gone from
mcp.yaml and its value unset, a plaintext token looked like the member's own
server and the exclusion went. It now fails closed and works per entry: a
pattern goes only when its file is gone, holds no server, or holds none of
teamai's servers with managed-mcp.json still there to say what teamai wrote.
A kept entry is named with its file, why, and how to clean it by hand, since
a rerun of uninstall finds no config after a full uninstall.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:527 judges owned entries using only the current server definition. If an earlier pull wrote jira from ${TOKEN}, the tool is later disabled, and the team changes jira to a definition without variables, the stale plaintext token is no longer recognized; pull adds no exclusion and doctor reports nothing, so git add -A can commit it.
  • [P1 blocking] src/mcp-reconcile.ts:234 only revisits current mappings and built-in defaults. If a previous pull wrote a token to a custom mcpProject path and the team later changes or removes that mapping before the member upgrades, the old file is never inspected or excluded and can be committed.
  • [P1 blocking] src/uninstall.ts:542 treats the existence of any manifest file as proof for every MCP target. If one target’s ownership record is missing while another target still has records—or cleanup writes an empty {} manifest—a valid file containing a dropped server and an unset token is considered clean at src/uninstall.ts:574; uninstall removes its exclude line and exposes the plaintext value.
  • [P2 non-blocking] src/uninstall.ts:544 still cannot associate a nested repository’s linked-worktree files with an MCP target. A clean mcp.json in that linked worktree is classified as “no tool teamai knows reads it,” so uninstall retains the shared exclusion indefinitely. This previously reported finding remains unresolved.

The other earlier findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…proof, skip unlocked writes

# Conflicts:
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
…lved value into it (Tencent#882)

Pull listed the file in .git/info/exclude only after writing the plaintext,
and a failed exclusion only warned, so the secret-bearing file stayed
eligible for git add -A. The exclusion now comes first; when it cannot be
established (exclude file or .git/info not writable, lock held past the
wait, file already tracked, git error) the file is left as it was and the
warning names the reason and the fix.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:525 treats an owned server as safe when its current definition keeps the same name but removes ${VAR}. If an earlier pull wrote a token and the tool is subsequently disabled, dropped and needing are both false and the value scan has no referenced variable to inspect; pull and doctor skip the file, allowing git add -A to commit the stale token.
  • [P1 blocking] src/mcp-reconcile.ts:234 still enumerates only current mappings and built-in defaults. If an earlier pull wrote a token to a custom mcpProject path and the team later changes or removes that mapping, the old file is never inspected or excluded, so it remains commit-able.
  • [P1 blocking] src/uninstall.ts:542 considers the existence of any manifest file proof for every target in that worktree. With an empty {} manifest—or records only for another target—a config containing a dropped server and an unset token reaches src/uninstall.ts:574 as clean; uninstall removes its exclude entry and exposes the plaintext value.
  • [P2 non-blocking] docs/usage-guide.md:1163 documents the runtime behavior, but the affected agent-facing MCP and uninstall guidance under skill-data/setup/references/ remains unchanged, contrary to the repository rule requiring behavior changes to update affected skills.

The earlier nested-repository linked-worktree issue and the other previously resolved findings remain fixed. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…ed value

Tencent#882's pre-write exclusion is now the single gate for a project MCP config
git would commit. Tencent#880's separate tracked-file check (gitTracks in
buildDesiredMcpContext, `withheld` in desiredMcpForTarget, its warning,
mcp list lines and doctor note) is removed: a tracked file is one more way
the exclusion fails, reported once with `git rm --cached <file>` and rotate.
A dry run (mcp list, doctor) now also names a tracked file before any pull
has listed it, mcp list reports a withheld file with an entry already
installed, and doctor reports withheld servers without the pull --force text.
A tracked file now gets no resolved value, declared secret or not.
…ready installed (Tencent#882)

Backports Tencent#880's merge 0d9f7fa: a dry run (doctor, mcp list) names a
tracked file before any pull has listed it, mcp list reports withheld for a
server an earlier pull installed, and doctor's withheld note carries the
exclusion's own fix instead of the pull --force advice.
…clude (Tencent#882)

A tracked file needs `git rm --cached` whatever else is wrong, so
ensureExcludedFromGit checks gitTracks before the writability check, on a
pull and a dry run alike, and lists nothing for it.
…olds no resolved value (Tencent#882)

A pull that lists a config in .git/info/exclude and then writes no value
into it (it does not parse, a member's server holds the team's name, the
write fails) removes the line it added. After a pull or `teamai mcp
remove`, a line whose configs are proven clean in every worktree, by the
proof uninstall uses (moved to mcp-reconcile.ts), is removed under the
lock; one not proven clean stays. A config listed before its write is
listed again after it, so a concurrent uninstall that dropped the line
between the check and the write does not leave the value unprotected.
…re the pull rewrote it (Tencent#882)

A pull whose manifest was lost before it ran recreates managed-mcp.json
while reconciling, so the clean-file proof read the new record and took
the exclude line out of a file still holding a teamai server that left
mcp.yaml with its variable unset. The proof now uses this worktree's
manifest as read before the reconcile.
A line this pull added and took back out, because it wrote no resolved
value into the file, was reported as removed although the member never
saw it added. Only removing a line an earlier run added stays at info.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:535 treats an owned entry as safe when its same-named definition stops referencing ${VAR}. If a disabled tool retains the old plaintext token, neither dropped, needing, nor the current-definition value scan detects it, so the file remains commit-able.
  • [P1 blocking] src/mcp-reconcile.ts:245 visits only current mappings and built-in defaults. If a team replaces or removes a custom mcpProject path, the previously written file is never inspected or excluded, allowing its resolved token to be committed.
  • [P1 blocking] src/mcp-git-exclude.ts:180 checks whether the file is tracked only when check-ignore returns would-commit. If check-ignore errors while rev-parse still locates the repository, a tracked file gets an exclude line and the second error is accepted as success at src/mcp-git-exclude.ts:215; reconciliation can then write plaintext into a file that Git already tracks.
  • [P2 non-blocking] src/mcp-reconcile.ts:593 still cannot associate a nested repository’s linked-worktree files—added by src/mcp-git-exclude.ts:283—with MCP targets. Even a missing-token, clean config is classified as unreadable by any known tool, so uninstall cannot remove its shared exclusion.
  • [P2 non-blocking] src/mcp-cmd.ts:79 reports a server as “withheld” for every target requiring resolution without applying its tools, transport, policy, exclusion, or requirements filters. For example, a Cursor-only server can incorrectly be reported as withheld from Claude.
  • [P2 non-blocking] The runtime behavior is documented only in docs/usage-guide.*; the affected agent-facing MCP/uninstall guidance under skill-data/setup/references/ remains unchanged, contrary to the repository rule requiring behavior changes to update affected skills.

The other previously fixed findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Sep 29, 2026
…holds a server (Tencent#882)

A pull or `teamai mcp remove` judged every linked worktree's MCP config
with today's definitions and values. Once a server's ${VAR} became a
literal, and the value was no longer set, a pull in worktree A took
worktree B's stale token-bearing entry for clean and removed the shared
/.mcp.json line, so `git add -A` in B staged the token.

These commands now release a line only when the current worktree's file
passes the full proof and every other worktree's file is missing or holds
no MCP server. `teamai uninstall` keeps its full proof in each worktree.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:3275 sets uninstalling merely because the response contains an uninstall_teamai command, so src/local-agent.ts:3293 skips credential protection even if that command fails or performs a non-last-agent uninstall that intentionally leaves shared MCP files intact. A pre-upgrade, unexcluded project MCP credential therefore remains visible to git add -A after this sync. Skip protection only after a successful uninstall that actually removes shared resources.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…amai too: a failed or partial one leaves what to keep out of git (Tencent#882)
@github-actions

Copy link
Copy Markdown

Review Result

  • No findings in the current diff.
  • The previously reported uninstall_teamai protection issue is resolved: workspace MCP protection now runs after successful, failed, or partial uninstall commands.
  • The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@jeff-r2026

Copy link
Copy Markdown
Collaborator

Please resolve the conflicts

# Conflicts:
#	docs/designs/data-directory-layout.md
#	docs/usage-guide.md
#	docs/usage-guide.zh-CN.md
#	skill-data/setup/references/manage-admin.md
#	src/__tests__/doctor-mcp-delivery.test.ts
#	src/__tests__/mcp-cmd.test.ts
#	src/__tests__/mcp-git-exclude.test.ts
#	src/__tests__/mcp-reconcile.test.ts
#	src/doctor-delivery.ts
#	src/mcp-cmd.ts
#	src/mcp-git-exclude.ts
#	src/mcp-reconcile.ts
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:2950 persists resolved: false before writing the replacement config. If an older, unexcluded credential-bearing entry is being replaced with a bare command and writeJsonDoc then fails—for example because the config directory is read-only—the old credential remains, but localAgentCredentialFiles trusts the false flag and skips its on-disk credential check. The post-sync migration therefore leaves the plaintext file commit-able. Persist the manifest only after the config write succeeds, or verify the recorded hash before trusting resolved: false.
  • [P1 blocking] src/mcp-reconcile.ts:920 reduces a historical Copilot file to server names before comparing it with current owners. If Copilot previously wrote a tokenized bare jira, later moves elsewhere, and Claude now owns a different nested mcpServers.jira in that file, the merged name is considered owned and the stale bare token is missed. Pull, doctor, or uninstall can then release or omit the exclusion. Historical-file evidence must preserve and inspect Copilot’s shadowed bare entry rather than merging it by name.

The previously reported findings remain resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…lds, not by the new install's resolved: false (Tencent#882)

install_mcp records the new entry before writing the config. When that
write fails, the older entry, credential included, stays in the file
while the record says resolved: false. localAgentCredentialFiles now
trusts resolved: false only while the entry on disk has the recorded
hash; otherwise it checks the entry itself. The doctor fixture that
used a placeholder hash now records the hash an install writes.
…another tool owns under mcpServers (Tencent#882)

recordedMcpFileEvidence merged a Copilot project file's bare servers with
those under mcpServers by name, so Claude owning a nested jira read as
owning a bare jira Copilot wrote with a token before it moved. Bare
servers now count as owned only by a Copilot record: no other tool
writes there.
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1346 removes every unmapped built-in fallback before the HTTP migration scan. A pre-57636a27 local agent can have written a credential to CodeBuddy’s former .codebuddy/mcp.json without managed-mcp-files.json; after upgrading, only the current .mcp.json target is inspected, leaving the old credential file visible to git add -A. Include known historical defaults in the local-agent migration scan.
  • [P1 blocking] src/mcp-reconcile.ts:657 merges Copilot’s bare entries with mcpServers, with the nested entry winning on duplicate names. If an older Copilot bare jira contains a credential and another tool later writes a credential-free mcpServers.jira, localAgentCredentialFiles examines only the nested value; the existing Copilot record also suppresses the unrecorded-entry scan, so sync and doctor miss the bare credential and leave it commit-able. Inspect bare and keyed entries separately for HTTP-team protection.

The two findings from the September 30 review are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

…efault and a bare Copilot entry apart (Tencent#882)

An HTTP team has no teamai.yaml history, so localAgentCredentialFiles never
visited a built-in default teamai has since changed: a credential a local
agent from before 57636a2 wrote to .codebuddy/mcp.json stayed committable.
It now also reads EARLIER_BUILTIN_MCP_PROJECT (earlierMappedMcpTargets with
history: false), in sync and doctor alike.

It also judged a Copilot project file through installedMcpEntries, which
merges bare servers with mcpServers by name, the keyed one winning. A
tokenized bare entry beside a credential-free mcpServers entry of its
name, both under one record, went unseen. Each entry is now judged on its
own (mcpEntriesByPlacement, which recordedMcpFileEvidence uses too).
@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1785 identifies a historical TeamAI bare Copilot entry solely by matching its hash against the current ownership record. Concrete failure: a member already has a top-level jira identical to the team definition, TeamAI installs the same server under mcpServers, and a later pull/update/remove sees the matching hash and deletes the member-owned top-level entry. The record must also prove TeamAI originally wrote the bare placement.
  • [P2 non-blocking] src/local-agent.ts:2951 writes the Git exclusion and sidecar before persisting the ownership manifest. If that manifest write fails, no MCP entry is written, but the exclusion remains; HTTP mode never releases these lines, and uninstall cannot remove it without the ownership record. This can permanently hide an otherwise user-owned project MCP config from Git after a failed install.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1816 and src/local-agent.ts:2943 still apply bare Copilot ownership to the keyed mcpServers entry by name. Concrete failure: TeamAI previously wrote bare jira, then a member adds their own mcpServers.jira; a later update overwrites the member entry, and dropping/uninstalling jira deletes it. Scope ownership checks to the recorded bare placement before modifying the keyed entry.

The previously reported findings are resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/mcp-reconcile.ts:1790 treats every ownership record without bare: true as ownership of the keyed mcpServers entry. Legacy Copilot bare records lack this new field, as does a record left if the post-write manifest update at src/local-agent.ts:2961 fails. If a member later adds a same-named keyed server, pull overwrites it at src/mcp-reconcile.ts:1843, while local-agent install/uninstall can overwrite or delete it through src/local-agent.ts:2944 and src/local-agent.ts:3060. Require positive keyed-placement evidence—such as a matching keyed hash—before an unmarked record can claim that entry.

The other previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P1 blocking] src/local-agent.ts:2953 replaces an existing Copilot ownership record with the new hash and potentially removes its placement before writeJsonDoc succeeds. If updating a proven bare or legacy entry fails while writing the config, the old entry remains but no longer matches the manifest. A later uninstall_mcp reports success while leaving TeamAI’s old credential-bearing entry behind, and a retry can create a keyed copy without removing the bare one. Preserve the prior record until the config write succeeds, or restore it on failure.

The previously reported findings appear resolved. The PR description documents sufficient unit, e2e, and representative real-CLI testing.

@github-actions

Copy link
Copy Markdown

Findings

  • [P2 non-blocking] src/local-agent.ts:2961 does not roll back a newly added Git exclusion when an update subsequently fails. For example, updating an existing credential-free managed server to a URL-bearing entry adds the exclusion, but a read-only config directory makes writeJsonDoc fail; the old clean config remains silently ignored until full uninstall because HTTP protection only adds exclusions. Remove the newly added exclusion when the config is unchanged/restored.

The previously reported ownership-loss issue is resolved: failed updates retain the prior manifest record and restore the prior config.

The PR description documents sufficient unit, e2e, and representative real-CLI testing. Per instruction, I did not execute tests or PR code.

@jeff-r2026
jeff-r2026 merged commit fb9f5a2 into Tencent:main Sep 30, 2026
13 checks passed
SaulMoro added a commit to SaulMoro/teamai-cli that referenced this pull request Oct 7, 2026
listWorktrees read only `git worktree list --porcelain -z`, so on git
without -z it returned [] to every caller. A pull or uninstall then judged
the shared .git/info/exclude line from the current checkout alone and
dropped /.mcp.json while a sibling worktree's .mcp.json still held a
resolved value (Tencent#882/Tencent#886).

Fall back to the newline-split list when -z fails. Liveness is unchanged:
completeWorktreeList stays -z only and returns null there.
jeff-r2026 pushed a commit that referenced this pull request Oct 9, 2026
* fix(opencode): add team rules and instructions to V2 sessions through the plugin

OpenCode V2 parses `instructions` and ignores it, so a V2 session got none
of the team rules or the culture, claudemd and recall blocks. The teamai
plugin's V2 setup now registers the session context and compaction hooks
and adds the user instruction file and rules, then the nearest project's
.opencode/teamai-context.md and .opencode/rules/**/*.md, sorted by path.
V1 delivery through `instructions` is unchanged. (#993)

* fix(opencode): write the user instruction file beside the Claude fallback for V2

OpenCode V1 reads ~/.claude/CLAUDE.md while ~/.config/opencode/AGENTS.md
does not exist, so teamai wrote no OpenCode user file there. V2 reads
neither that fallback nor `instructions`. Pull and HTTP prompts now
write ~/.config/opencode/teamai-context.md for teamai's plugin in that
case too, and still list it nowhere, so V1 gets no duplicate. (#993)

* fix(init): set up only the --agent tools in single-repo init (#993)

init . wrote .teamai/teamai.yaml (mode: self) before it read the existing
project config. With the marker on disk and no config yet, that read ran the
clone-time self-heal, which enabled every tool found in HOME, injected their
hooks into the repo and saved them; init then merged --agent into that.

Read the config once, before the marker is written, and use that snapshot for
inheriting user scope, the enabledAgents union, the tool-roots carry-over and
the mode-switch settlement.

* fix(coauthor): keep the project-scope co-author setting out of shared settings (#993)

In project scope only claude writes attribution, to the member-local
.claude/settings.local.json; the rest of the Claude settings family is
user-scope only, like Codex and Cursor. The next pull removes an
attribution an earlier release wrote to a shared project settings file
when it is exactly teamai's value and the co-author record lists the
file, deleting only that member's text.

* docs(coauthor): note a pre-fix shared value stays while no co-author choice exists (#993)

* fix(doctor): check OpenCode V2 delivery from the teamai plugin

doctor reported the team rules and instructions active on OpenCode V2
because `instructions` listed them, but V2 ignores that key. doctor now
reads the major version from `opencode --version` (missing or unparsable
counts as V1) and, on V2, checks that teamai's plugin in HOME is the one
this build writes, since its context hook is what delivers them. V1
checks are unchanged. The usage guide describes V2 delivery. (#993)

* fix(pull): probe live checkouts from recorded roots, not the worktree list (#993)

A pull in a linked worktree of a --separate-git-dir repo or a submodule
dropped the main checkout's delivery record and workspaces/<id>/, because
git worktree list names the git directory there instead of the main
checkout. Its next pull then overwrote edited rules without a word.

Records and workspace directories now store their checkout root; a full
pull keeps one while that root still has a .git, names the same git common
dir, and (records) gives the same checkoutKey, so a re-created worktree
still starts fresh (#807). Entries without a root (older CLI) are kept.
listWorktrees reads --porcelain -z, skipping bare, prunable and common-dir
entries, and returns [] when -z is unsupported instead of splitting lines.

* fix(init): skip the clone-time self-heal for init in a single-repo clone (#993)

A fresh clone whose committed .teamai/teamai.yaml says mode: self ran the
self-heal in the preAction queue check and again in initSelfRepo's config
read. Either one enabled every tool found in HOME before --agent could
choose. init now loads with selfHeal: false in both places; every other
command still self-heals.

* fix(sync): adopt config entries that equal a team render, keep the member's (#993)

An MCP server or a team hook entry teamai has no record of is teamai's
only when it equals teamai's render of a team server or hook, today or at
any revision in the team repo's history (one hook only). It is then
adopted and handled as recorded. Any other one is the member's: kept and
named by pull and doctor; a member's same-name MCP server means the team
server is not written to that file. `pull --force` no longer overwrites
a member's server (`teamai mcp inject --force` still does).

This removes the second copy of each team hook a lost hook manifest
caused in .claude/settings.local.json and .codex/hooks.json.

* docs(sync): describe ownership of unrecorded MCP servers and hook entries (#993)

* test(init): cover init . in a single-repo clone with no --agent and no terminal (#993)

With the clone self-heal skipped for init, a fresh clone has no config when
init runs. Pin that it takes the non-interactive default (the tools found in
HOME) instead of prompting or stopping at "already initialized".

* test(hooks): cover hook ownership beside the co-author setting in settings.local.json (#993)

* fix(pull): prune a workspace directory without a root only when the worktree list names every checkout (#993)

contribute, recall, viz, the MCP writers and the local agent create a
checkout's workspaces/<id>/ before its first full pull writes root, so a
removed worktree that never ran a full pull kept its directory forever
(self-mode-worktrees-808). Such a directory now goes when git worktree list
-z names every checkout (no non-bare entry is the common dir) and none has
its id, as git prints it or realpath'd. It is kept with --separate-git-dir,
in a submodule, on git < 2.36, and when it holds local-agent/.

* fix(mcp): create a missing .git/info/ before excluding a resolved MCP config (#993)

A repository created with an empty template has no .git/info/. The
writability check ran `access(W_OK)` on that directory, got ENOENT, and
reported it as "not writable", so every team MCP server holding a
resolved value was withheld.

Check the exclude file when it exists, otherwise its closest existing
directory, and let the write create info/. Report "not writable" only
for EACCES, EPERM and EROFS, and name the exclude file in the reason and
the fix. Applies to pull, doctor, `mcp list` and local-agent installs,
which share ensureExcludedFromGit.

* fix(mcp): write CodeBuddy's user MCP servers to the file CodeBuddy reads (#993)

CodeBuddy reads only the first user MCP file that exists of
~/.codebuddy/.mcp.json, ~/.codebuddy/mcp.json and ~/.codebuddy.json, and
teamai always wrote the second, so team servers and the member's own hid
each other. In user scope teamai now writes to that first existing file and
creates ~/.codebuddy/.mcp.json only when none exists. Each CodeBuddy record
in managed-mcp.json names its file; a pull moves teamai's own entries out of
a file CodeBuddy no longer reads, uninstall and `teamai mcp remove` follow
the record, and doctor and `teamai mcp list` name a shadowed file.

* fix(local-agent): install CodeBuddy's user MCP servers in the file CodeBuddy reads (#993)

An HTTP-backed team's install_mcp now writes CodeBuddy's user servers to the first existing file of CodeBuddy's lookup order, as a pull does, and records that file. A reinstall moves a server an earlier install left in a file CodeBuddy no longer reads; uninstall_mcp removes it from the recorded file.

* fix(migrate): move the team clone's last-fetch stamp out of a superseded install (#993)

A linked worktree whose old git-mode install another checkout superseded
by switching to single-repo mode keeps the team knowledge in .teamai/ and
moves only SUPERSEDED_ENTRIES to .teamai.bak. #964 added last-fetch.json
beside the clone, which that list did not name, so it stayed in .teamai/
as an untracked file. The 808 fixture now writes the stamp a #964 release
leaves, so the case fails without the fix whatever teamai the developer
has installed.

* fix(hooks): keep the managed-hooks index and the package lock out of the working tree (#993)

getManagedHooksPath built <root>/.teamai/managed-hooks.json in project
scope, so the Copilot hook index showed up untracked in every git project
and self mode committed it with git add -A. It now resolves per checkout to
<dataHome>/workspaces/<id>/managed-hooks.json, beside managed-mcp.json.
legacyManagedHooksPath names the old file for the pre-#370 Codex import,
the legacy hook sweep and uninstall. migrateLegacyManagedHooks moves its
Copilot records (all records in self mode) before hooks are reconciled,
removed or uninstalled, and deletes it once empty and untracked; doctor
names a tracked one with the git rm --cached command.

teamai.lock moves to the data home too (packageLockDir): an older
.teamai/teamai.lock is moved on the next install or session-start read,
doctor reads it where it is, and a .teamai/.gitignore holding only
teamai.lock is removed outside self mode. Self mode's .teamai/.gitignore
lists managed-hooks.json, also through its migration.

* fix(pull): keep a rule or agent file teamai cannot prove its own (#993)

A rule or agent destination that exists with no delivery record is
teamai's only when its bytes equal teamai's render of that resource at
some revision of the team repo's history (git blob ids, through ticket
04's matchesHistory). Otherwise it is the member's: pull neither writes
nor deletes it, names it, and doctor lists it. This covers a checkout
whose record key changed (a restore or copy gives .git a new inode), a
role switch after that, and a member's own file with a team name.

- DeliveryTarget.origin carries the proof; judgeCopy gains a 'member'
  verdict; isTeamaiCopy and describeMembersFile are the shared seam.
- The rules stale sweep and the tombstone cleanup prove an unrecorded
  copy before deleting it.
- A kept copy is worded as an edit when another checkout record lists
  its path (a record lost to a new key); no record is carried over.
- A pull that keeps a member's file does not count as synced, so a
  plain pull delivers the team version once the file is gone.

* fix(mcp): leave an older teamai's ~/.codebuddy/mcp.json that hides ~/.codebuddy.json (#993)

Earlier releases always created ~/.codebuddy/mcp.json, so a member's servers in ~/.codebuddy.json stayed hidden. When that file holds only teamai's servers (recorded, or equal to a team render) and a later file of CodeBuddy's lookup order exists, a pull moves the servers there, deletes the file and says so once. A file that also holds a member's server or any other key stays chosen.

* fix(remove): judge rule copies by record and team history in teamai remove (#993)

teamai remove's rules refresh ran without the checkout's ledger, so it
wrote over a member's own file at a team rule's path, and its explicit
sweep deleted a member's own file named after the removed rule. It now
opens the ledger, passes it to the refresh and names what it kept, and
deletes a copy of the removed rule only when it is on record or its
bytes are a version from the team history (the author's root copy keeps
its placement record as proof). Docs say a personal rule the team never
had is kept, and that a pull keeping a member's file syncs in full until
it is gone.

* fix(pull): keep a skill or doc teamai cannot prove its own (#993)

A skill directory with no delivery record is teamai's only when every file
in it is today's team file or a version of it from the team repo's history
(SKILL.md also as pull repairs its frontmatter); one file of the member's
makes the whole directory theirs. A file in the docs mirror at a team doc's
path is teamai's only when it is a version of that doc. Anything else is
kept and named, not written over or removed by the tombstone cleanup, and
doctor lists it. isTeamaiSkillCopy is the ledger-free decision ticket 05
reuses.

* docs(skill): keep the ownership sentence clear of the command check (#993)

* fix(rules): delete a legacy .md in .cursor/rules only when it is teamai's (#993)

Pull's stale sweep and pullItem deleted every .md in a .mdc tool's rules
directory, and uninstall every one with a team rule's name, as a copy of
the layout before .mdc. A member's own notes.md there went with them,
though the usage guide promised it stays. Each now deletes one only when
the checkout record lists it, it is a team version from the history
verbatim, or it has a built-in rule's name (isLegacyLayoutCopy); any
other is kept silently, since Cursor never reads it. teamai remove
already applies the proof since 02b.

* fix(coauthor): move a pre-fix attribution to settings.local.json when no choice exists (#993)

With no co-author choice (the team dropped sharing.coAuthor and the
member has none), the reconcile returned before settling shared project
files, so an attribution an earlier teamai wrote to a tracked
.claude/settings.json stayed there for git add -A to commit. The next
pull now moves it to .claude/settings.local.json under the same guards
(teamai's exact value, the record lists the file with "strip", only
that key is edited), so the member's trailer is unchanged and the
tracked file is clean. A value the member already set in
settings.local.json is kept. Other Claude-family tools' shared files
have no member-local counterpart and wait for a choice. A failed
settle now keeps its record so the next pull retries.

* fix(git): list sibling worktrees on git before 2.36 (#993)

listWorktrees read only `git worktree list --porcelain -z`, so on git
without -z it returned [] to every caller. A pull or uninstall then judged
the shared .git/info/exclude line from the current checkout alone and
dropped /.mcp.json while a sibling worktree's .mcp.json still held a
resolved value (#882/#886).

Fall back to the newline-split list when -z fails. Liveness is unchanged:
completeWorktreeList stays -z only and returns null there.

* fix(mcp): report an exclude directory teamai cannot create as not writable, not as a held lock (#993)

acquireLock returns false when it cannot create the lock's directory, so
updateFileLocked waited out 25 attempts and reported "another teamai
command held <file> past the wait". With .git/info a regular file, MCP
exclusion blamed a lock nobody held.

updateFileLocked now creates the file's directory before taking the
lock. EEXIST and ENOTDIR (a path on the way is not a directory) and
EACCES, EPERM and EROFS throw a NotWritableError naming the file and the
closest existing path above it, without waiting. ensureExcludedFromGit
reports it as "<exclude> is not writable, as <path> is not a directory"
and withholds the server as before. acquireLock's boolean contract is
unchanged for its other callers.

The e2e fixtures now use fresh paths per call, so a retried case
rebuilds them instead of failing on the first try's.

* docs(mcp): say an unrecorded entry under a name the team dropped is not adopted (#993)

MCP ownership adopts an unrecorded entry only under a server name the
team defines today, so a copy of a since-deleted team server stays as it
is. Say so in the usage guide's ownership paragraph, in both languages.

* fix(agents): prove an agent copy an older CLI rendered teamai's (#993)

The agent ownership proof accepted a team version verbatim or rendered as
pull renders it today, with today's aliases. Every member upgrading from
0.26.0 or earlier has no delivery record, and 0.26.0 rendered agents
differently: the spec's model as written (no aliases), and before #830
Qoder, Qoder CN, ZCode and OMP took Claude's tool_extras and tclaude and
tcodex only their base tool's. Such a copy read as the member's: never
updated, named on every pull, and every pull a full sync. agentOrigin now
also accepts the unresolved render and the pre-#830 extras render.

* fix(pull): keep a member's file when the team deletes the doc at its path (#993)

The docs mirror prune deleted every local file the team repo no longer has.
At a path the team once had a doc, it now deletes the file only when it is
a version of that doc from the team history; any other is kept and named,
and doctor no longer lists it as stale. A local-only file at a path the
team never had is pruned as before.

* test(e2e): isolate HOME and the teamai binary for fixture git

The git hooks teamai installs run `PATH="$HOME/.teamai/bin:$PATH" teamai
hook-dispatch ...`. E2E fixtures ran git with the real HOME and PATH, so a
fixture merge or checkout ran the developer's installed teamai against the
real ~/.teamai (it rewrote ~/.teamai/bin/teamai and left fixture projects
in ~/.teamai/projects). CI has no teamai, so local runs diverged from CI.

A new e2e setup file gives each test file a sandboxed HOME (with a git
identity), unsets XDG_CONFIG_HOME so XDG paths follow HOME, sets
GIT_CONFIG_NOSYSTEM=1, and puts a `teamai` on PATH that runs the dist under
test; all are restored afterwards.

The remote suite in e2e.test.ts now clones the fixture team repo into the
sandbox itself instead of relying on CI preparing the real ~/.teamai. The
#808 single-repo case merges with the member's HOME, as a member's hook
would run.

* fix(pull): retry a resource a member's file held back even at the same revision (#993)

A pull that keeps a member's file did not advance the revision, so the
next pull would sync in full and deliver once the file was gone. When
the checkout was already at that revision (pull --force, or the first
pull after an older CLI's), the next pull still took the fast path and
the team version never arrived, though the message says a pull brings
it. Such a pull now resets the checkout record's revision (the shared
revision in user scope), as a forced full sync does.

* fix(doctor): report a .git/info that is a regular file as not writable on a dry run (#993)

Doctor checks MCP exclusion with a dry run, which never reaches
updateFileLocked and reads the unreadable exclude file as empty, so with
.git/info a regular file it reported "not injected" and pointed at
`teamai pull`. ensureExcludedFromGit's writability check now fails when
the closest existing path above the exclude file is not a directory, so
doctor's `MCP servers delivered to <tool>` gives the same not-writable
reason and fix as pull.

* fix(skills): deliver Codex skills into .agents/skills only over teamai's copy (#993)

Codex reads the shared .agents/skills, which other tools and the member
write to as well. The destination resolver took any existing copy there as
teamai's and wrote the team skill over it, silently at init. It now takes
an ownership predicate: pull asks judgeCopy against the checkout's record,
doctor the team history alone, and teamai remove and uninstall
isTeamaiSkillCopy. A copy that is not teamai's is left alone; the team
skill goes to .codex/skills/<name>, and every full pull names the conflict.
remove and uninstall delete only teamai's copy, in project and user scope.
Source skills and the built-in stub keep the old rule for now.

* test(doctor): give the older-copy fixtures a team history (#993)

Two doctor-delivery-cli cases wrote a stale agent and Cursor rule copy by
hand, with no delivery record and no team history: since #993 such a copy
is the member's, so doctor reported it as "not teamai's". Each now commits
the older version to the team repo first, so the copy is teamai's older
render, as the case means. The Cursor case restores the team rule and its
copy in a finally block, so a failure no longer breaks the cases after it.

* fix(skills): delete only teamai's skill directories on remove, uninstall and pull (#993)

teamai remove, uninstall, and pull's sweep of namespace-nested copies of an
excluded skill deleted any directory at a team skill's name, including a
member's own skill that pull keeps as not teamai's.

remove and uninstall now delete a skill directory only when a file under it
is on the checkout's record, or isTeamaiSkillCopy proves it (what pull writes
today from the team source, or a team version by the history), and name each
one they leave. remove judges before deleting the team source, so the local
agent's history-less resource cache still removes its own installs. uninstall
keeps trusting built-in names and names the local agent's manifest lists.
The nested sweep deletes a copy only when removedCopyChanged says it is
teamai's and unchanged.

* test(uninstall): drop the commitTeamRepo import the merge duplicated (#993)

* fix(source): deliver source skills where team skills go (#993)

Source skills resolve through the team-skill seam: the agent-exclusion
check, skillTargetForTool (Hermes home, OpenClaw workspace, Copilot home),
then Codex's shared-destination rule with a real ownership proof against
the source repo. Destinations outside the project are recorded as absolute
paths inside the tool's home and shared per source across projects. An
unrecorded existing copy that is no version of the source skill is the
member's and is kept. Copies earlier releases left in the project's
.hermes/skills and .openclaw/skills are reclaimed when unchanged, kept and
named otherwise.

* test(source): changed legacy source copies survive uninstall, remove and withdrawal (#993)

* test(pull): pin delivery of an agent held at the team revision once its alias resolves (#993)

A pull --force that holds an agent for its model leaves the checkout record at the team revision, so the next pull takes the fast path. The fast path's agent redeploy (#830) already writes the resolved model there, and a copy held before delivery is written as missing; no code change is needed. The test fails when that redeploy is disabled.

* fix(uninstall): delete only teamai's rule and agent copies on uninstall, remove agents and pull (#993)

uninstall and `teamai remove agents` deleted every rule or agent file at a
team name, and pull deleted the namespaced copy beside the author's root copy
on tools that do not flatten rules. Each now goes only on the checkout's
record or a team version by the history; built-in names stay deletable, and a
member's file is kept and named.

* fix(packages): leave a tracked .teamai/teamai.lock in place and name it in doctor (#993)

Moving a lock git tracks left a deletion in git status. A tracked (or
unknown) lock is now copied to the data home instead, and doctor names it
with git rm --cached, as it does the tracked hook index.

* fix(docs): keep a local doc when the team history cannot be read (#993)

The docs prune deleted a file the team no longer has when git could not
read the team history. Without proof the file is the member's, as
everywhere else.

* test(skills): cover an unrecorded older team copy in .agents/skills updated in place for Codex (#993)

* ci(e2e): drop the steps that prepared the real HOME the e2e sandbox replaces (#993)

The e2e setup file gives every test file a sandbox HOME with its own git
identity, and e2e.test.ts clones the fixture repo into it, so the git
identity, teamai config and fixture cleanup steps act on a HOME nothing
reads.

* docs(ci): describe the e2e HOME sandbox in the local e2e setup (#993)

* refactor(sync): build every not-teamai's line from one reason (#993)

* test(agents): prove the removed and superseded copies teamai's by the team history (#993)

* test(pull): give the docs prune fixtures a team history (#993)

* fix(source): name the source's version, not a team version, for a member's skill at a source skill's path (#993)

* refactor(local-agent): resolve a record's MCP file through recordedFileOf (#993)

* fix(coauthor): report a recorded settings file that is not JSON as unreadable, naming it (#993)

* test(mcp): wait for the session start's background pass before the git-info fixture cleans up (#993)

hook-dispatch leaves a detached --bg-only pass running in the sandbox HOME
after the foreground hook exits; it was still writing when afterAll removed
the sandbox, so CI failed the suite with ENOTEMPTY.

* test(opencode): keep the V1 hooks e2e off the npm registry (#993)

With any plugin configured, OpenCode's first request for a directory waits
for it to npm-install @opencode-ai/plugin into its config dir. On CI that
fetch outlasted the 5 s session request. teamai's plugins import nothing
from it, so the fixture records it as installed, and a failed session
request now carries the server log.

* fix(sync): name a kept copy of a removed resource as the member's or as an edit (#993)

judgeRemoval replaces removedCopyChanged with three outcomes: remove,
edited (changed since teamai delivered it, or on another checkout's
record), and notTeamais (no record, no team version). Pull's tombstone
cleanup and the rules sweep now say a member's own file is not teamai's
instead of claiming the member changed a teamai copy.

* fix(agents): delete a stale same-stem agent sibling only when it is teamai's (#993)

removeStaleAgentSiblings deleted every same-stem file with another
extension beside a rendered agent, a member's own included. It now goes
through judgeRemoval with the agent's origin and the checkout record, and
names a sibling it keeps. Built-in agents keep sweeping their own names.

* test(agents): record the stale Kiro .md sibling as teamai's in the fixture (#993)

* fix(docs): keep a member's entry whose type conflicts with a team doc (#993)

The mirror replaced a local directory at a team doc file's path, or a
local file at a team docs directory's path, and deleted the backup with
its staging directory. membersDocs now treats such an entry as the
member's unless the team history proves it teamai's (a file where the
team now has a directory, or a directory whose every file is a team
version), and copyDocs leaves a kept entry in place. A link is still
replaced without touching its target.

* test(skills): expect the not-teamai's line for a member's copy of a removed skill (#993)

* fix(docs): keep a member's link at a team doc's path, without following it (#993)

membersDocs skipped links, so the mirror still moved a member's link
aside and deleted it with the staging directory (rest of the Codex P1).
A link at a doc's path, or at an ancestor of one, is now the member's
unless the team has the same link there: it is kept, named and never
followed, and the docs under it are not delivered.

* ci: re-run checks after an unrelated recall-attribution flake (#993)

Node 22 on macOS failed on 9309e201 with one unhandled rejection from
src/__tests__/recall-attribution.test.ts (a votes-lock write outliving the
test's temp HOME); every test passed and #995 does not touch that code.
The fork cannot re-run jobs, so this empty commit re-triggers CI.

* ci: re-run the Codex PR review, cancelled at its time limit (#993)

* fix(mcp): save the manifest when an adoption records a server it did not rewrite (#993)

An unrecorded server equal to today's team render was adopted into the
in-memory records, but with no file write the manifest was not saved, so
a later team removal or uninstall found nothing owned and left the server
installed. The reconcile now saves the manifest whenever this run changed it.

* fix(mcp): preview a no-op MCP adoption on --dry-run, and cover uninstall (#993)

A dry run now says it would record an unrecorded server that already holds
the team's render, in .mcp.json-style files and in Codex config, and still
writes nothing. The real-CLI cases add the dry run before the adoption and
an adoption followed by teamai uninstall; both failed before b62e503b.
Hook adoption needs no change: reconcileHooks saves its records whenever
they differ from the previous ones, whether or not it wrote a file.

* fix(docs): keep a member's directory where the team removed a doc file (#993)

When the team deleted a doc file the member had replaced with a directory,
the prune walked into that directory and deleted its files, which match no
team version. A directory at a path where the team history had a doc file,
holding anything that is not a team version, is now kept whole and named.

* fix(mcp): remove an unrecorded copy of a server the team deleted (#993)

With a lost manifest, a server an older release installed stayed for good
once the team deleted it: only today's names were judged, and with no team
server and nothing owned the reconcile returned early. Pull and uninstall
now also remove an unrecorded entry, in .mcp.json-style files and Codex
config, that equals a render of that server from the team history. A
member's entry under that name stays. The early return now also requires a
team history with no MCP server.

* perf(sync): read the team's MCP history once per pull, in one git call (#993)

historicalContents read each historical blob with its own git process
(about 370 ms for 60 versions); it now reads them all with one
git cat-file --batch (about 30 ms). The MCP reconcile reads that history
once per run for every target, and only when a target file holds an
unrecorded entry; with no team server, nothing recorded and no server in
any target file it returns without reading it. A dry run names an
unrecorded copy of a server the team deleted that a pull would remove.

* fix(docs): keep a member's link where the team deleted a doc (#993)

The prune unlinked any link at a removed team doc's path. A link there is
now removed only when it is a version of that doc from the team history
(git stores a link as a blob of its target); a link of the member's stays,
and is never followed. A link at a path the team never had is pruned as
before.

* fix(coauthor): keep the record of a shared settings file that does not parse (#993)

A pull that could not read a recorded shared settings file dropped its
record, so once the member repaired the file no later pull removed the old
attribution value. The record now stays until the file can be read.

* fix(docs): keep any non-file entry of the member's where the team deleted a doc (#993)

A link at a removed team doc's path goes only when it is a version of that
doc from the team history; any other entry that is not a regular file stays
and is named. Adds the real-CLI case for a member's link replacing a doc.

* fix(sync): never adopt or write through a link at a delivered file's path (#993)

isTeamaiCopy and the copy judgement followed links: a link at a rule or
agent path whose target held an older team version was taken as teamai's,
and the next pull wrote through it into the member's file. A link in place
of a delivered file is now the member's (kept, named), a link there is
never removed as teamai's copy, and a skill directory holding a link is not
teamai's copy, since copying over it would write through it.

* fix(docs): keep a member's directory of links where the team deleted a doc file, and do not call it stale (#993)

A directory that replaced a former team doc file and held only links read
as teamai's (links are not listed), so the prune removed it. Such a
directory is now the member's whenever it holds a link. doctor also stops
listing files inside a directory pull keeps whole as stale docs.

* fix(hooks): recognize unrecorded team hooks in every format, on pull and uninstall (#993)

Pull recovered a team hook entry whose record was lost only in Claude and
Codex files. In Cursor, Copilot and ZCode files the old entry stayed as the
member's and the new one was added beside it, so both ran. Each entry is now
compared with the team's hooks rendered the way that format's reconciler
writes them; one equal to exactly one hook is replaced or removed as teamai's.

Uninstall did not judge unrecorded entries at all, so with the records lost
it left custom team hooks installed, and missed a main-checkout Codex file
holding only team hooks. It now judges every hook file against the team's
hook history, gated to the project as pull renders them in a non-self
project scope, and removes the entries equal to exactly one team hook.

* fix(sync): treat any non-regular entry in a skill or docs directory as the member's (#993)

A directory is teamai's copy only when every entry is a regular file that
is a team version; a link or any other non-regular entry anywhere below it
makes it the member's. Adds real-CLI cases for a skill directory holding a
link and a docs directory of links where the team deleted a doc file.

* fix(sync): keep a member's link at any delivered path (#993)

A link at a delivered skill directory, rule or agent file, docs entry or
source skill destination is the member's: teamai never creates links, so
it is never adopted, replaced, written through or deleted, on pull,
remove and uninstall alike. Pull and doctor name it: "Kept <path>: it is a
link of yours, so teamai does not replace it. Remove the link to receive
<name> from the team."

This deliberately changes earlier behavior: source and team skills used to
replace a leaf link in place of a skill directory with a copy, and the
Codex duplicate cleanup deleted a configured leaf link. The source tests
that asserted those now expect the link kept.

* fix(agents): prove a tombstoned agent copy teamai's in a team-defined tool too (#993)

For a tool teamai renders no agents for, the tombstone cleanup had no
proof to check and removed any file with the removed agent's name. It now
removes it only when it is a team agent's own bytes from the history; a
member's file of that name stays.

* fix(rules): never read through, write through or delete a member's link at a rule path (#993)

The cleanup and rerender paths judged a rule copy by reading the file a
link points at, so a link whose target held a team render was rewritten
through or deleted: the "Already synced" rerender, the legacy rules
directories (pull and uninstall), the reserved teamai-context name, the
unselected-rule sweep, the moved nested copy, the flat OMP and Kiro
copies, the older .md layout, and teamai remove rules. Each now treats a
link as the member's and leaves it. The empty-directory sweep no longer
deletes a directory whose only entries are links.

* fix(agents): keep a member's link where an agent was delivered, on pull and teamai remove (#993)

The inactive-namespace cleanup judged a copy by the file a link points at, and teamai remove agents deleted the author's root copy by its placement record alone. A link at either path is now the member's and stays.

* fix(skills): keep a skill directory that holds a member's link, on pull, remove and uninstall (#993)

A recorded skill whose file the member replaced with a link to the same
bytes read as unchanged, so pull copied over the link and remove,
uninstall and the tombstone and inactive-namespace sweeps deleted the
directory with every link in it. A skill directory holding a link is now
the member's on pull, named by the link, and kept by every sweep.
teamai remove skills also judges a link at a built-in skill's name
before the name.

* fix(sync): keep an unrecorded file teamai cannot read where a removed resource was delivered (#993)

judgeRemoval counted a file it could not read as absent and removed it. Without its bytes nothing proves it teamai's, so it is now kept as the member's.

* fix(docs): never read through or delete inside a member's link in a deactivated docs namespace (#993)

Withdrawing a namespace's docs read each copy through any link on its path and deleted what matched the team file. A namespace directory the member linked to a folder of their own lost its files, and a linked doc was deleted. A path through a link is now left alone.

* fix(source): keep a source skill copy that holds a member's link, on pull and source remove (#993)

A recorded destination was the source's whole, so pull copied over a link the member put inside it and source remove deleted the directory with it. Such a copy is now the member's: pull names the link and source remove keeps the directory on record.

* fix(local-agent): keep a member's link where the local agent installs a skill or rule (#993)

The local agent installs through pullItem with no delivery record, which skipped every ownership check: a rule was written through a link and a skill directory link was replaced. A link at the path, or inside the skill directory, is now kept and named.

* fix(sync): deliver team and source skills without the links in them (#993)

Copying a skill recreated each link in its source as a link in the
member's tool directory, which then read as the member's own, and let a
skill plant a link to any target. Delivery now skips a link in a team or
source skill, without following it, and names each one once:
"Skipped <link> in <skill>: teamai does not deliver links."

* fix(docs): keep a member's link in the docs mirror at a path the team never had (#993)

This deliberately changes the mirror prune: it used to unlink any link at
a path the team repo's history never had, as a local leftover. teamai
never creates a link there, so such a link is the member's: pull now
keeps it and names it, and doctor no longer reports it as a stale doc.
A link matching a team link once at a removed doc's path is still pruned.
The docs and doctor tests that expected the unlink now expect it kept.

* docs(designs): state that teamai never follows, replaces or deletes a member's link (#993)

* fix(uninstall): remove only teamai's docs from the docs mirror, and keep the member's (#993)

Uninstall deleted the whole docs directory, so a file pull had kept as
the member's, a directory or a link of theirs went with it. It now
removes a file or link only when it is a version of that doc from the
team repo's history, names everything else, and removes a directory only
once it is empty. While the history cannot be read, the mirror stays
whole. A mirror inside the data home that keeps something survives the
removal of that home with it.

* fix(mcp): write a linked MCP config through its link, and keep its target out of git (#993)

A member's MCP config can be a symlink into a dotfiles repository. The
atomic writes replaced the link with a regular file. They now write at the
file the link points to, rollbacks included, and a linked CodeBuddy
~/.codebuddy/mcp.json is kept rather than deleted.

A resolved value then lands in the link's target, so the git checks judge
that file in its own repository: a target that repository tracks gets no
value, and an untracked one is listed in that repository's info/exclude,
which pull and uninstall now find to take the line out again.

* fix(docs): name a kept link at a removed doc's path as before, and only a never-team path's link as such (#993)

Goes with keeping a member's link at a path the team never had: a link at a removed doc's path keeps the line that says the team removed it.

* test(skills): expect pull to name the member's link inside a skill directory (#993)

* fix(opencode): keep a member's linked opencode.json and openclaw.json as links (#993)

The OpenCode instructions writer and the OpenClaw hook entry writer
replaced a symlinked config with a regular file, and uninstall deleted a
linked .opencode/opencode.json left holding only its $schema. Both now
write at the link's target, and a link is never deleted.

* fix(rules): never delete a linked CLAUDE.md or AGENTS.md when its teamai section goes (#993)

Removing teamai's section from an instruction file teamai created deleted
the file once nothing else was left, a member's link to it included. A
link stays; its target is emptied instead.

* fix(skills): push no links to the team repo, and name why remove and uninstall keep a skill directory (#993)

A push copied a link inside the member's skill into the team repo, so every
member then received a skill teamai will not deliver; push now skips each
link and names it. remove and uninstall now say a kept skill directory is a
link of the member's, or holds one, instead of claiming it has no delivery
record.

* fix(source): keep reading a record into a removed OpenClaw workspace, and never another repository's history (#993)

A source record of a copy in an OpenClaw workspace outside ~/.openclaw was
rejected once that workspace was gone, so every pull and source remove of
that source failed. The configured workspace now counts as a tool home even
when its directory is missing. A source cache without its own .git no
longer lets git read an enclosing repository's history (a dotfiles HOME):
only today's source proves a copy then.

* fix(source): leave a copy in a tool home that moved, instead of rejecting the record (#993)

Configuring OpenClaw to another workspace made the record of a copy in the
old one invalid, so every pull and source remove of that source failed.
The record now notes the tool home of each absolute destination; one whose
home moved is dropped from the record and its copy left as it is, while an
absolute path the record cannot account for is still rejected.

* fix(docs): keep the member's hidden files on uninstall, and match a link only to a team link (#993)

uninstall did not count hidden entries in the docs mirror as kept, so with
nothing else kept it deleted the data home around them. They are now named
and keep the mirror. A link in the mirror is now teamai's only when the
team history had a link with that target there, not a file holding the
same text (the history now carries each version's git mode).

* fix(skills): keep a delivered skill directory that holds a file the member added (#993)

A skill directory with any file on record was taken as teamai's whole, so
remove, uninstall and the tombstone cleanup deleted a file the member had
added beside teamai's. A recorded directory now goes only when every file in
it is on record or a team version; otherwise it is kept whole and named.
Source skill removal applies the same rule against the source's files and
history.

* fix(skills): remove and uninstall take only teamai's files from a skill directory (#993)

When a delivered skill directory holds a file the member added, remove and
uninstall now delete only the files on record or matching a team version,
name each file they keep, and leave the directory while anything is left in
it. uninstall plans the exact files, so its dry run shows them.

* fix(source): keep a member's file at the path of a link the source skill has (#993)

Delivery never copies a link from a source skill, but removal counted any
history at that path as the source's, the link included, so a regular file
the member put there was deleted with the skill. Only a regular file the
source has, or had, is the source's now.

* fix(mcp): find teamai's CodeBuddy servers in every user MCP file, and treat a linked lookup path as its target (#993)

A server an earlier teamai left in a CodeBuddy user MCP file that CodeBuddy no longer reads stayed there once its record was lost: pull and uninstall looked only at the file CodeBuddy reads, and returned early when that file was empty. They now also look at the other lookup files that exist and remove an unrecorded entry equal to a current or historical team render; a member's entry stays.

Lookup files are compared by real path. With ~/.codebuddy/.mcp.json a symlink to ~/.codebuddy/mcp.json, a pull recorded the server at the link and then removed it from its target, the same file. With ~/.codebuddy.json a symlink to ~/.codebuddy/mcp.json, the upgrade move emptied and deleted the file the link points to.

* fix(sync): prove a CONTRIBUTORS file like any other, and a file only by a file the team had (#993)

A skill's CONTRIBUTORS file counted as teamai's by its name alone, so remove
and uninstall deleted the member's own one, and it never made a directory
the member's. It now needs the same proof as any file. A link in the team
history no longer proves a regular file holding the same bytes, and a
version that changed only its mode is kept as its own version.

* fix(skills): keep a delivered skill when the team or the source adds a file where the member has one (#993)

A file the member added to a recorded team or source skill was overwritten
once the team, or the source, delivered a file at that path. Pull now keeps
such a copy whole and names it, as an edited one, unless the file is a
version from the history. A source removal keeps a copy holding a file of
the member's only when the source's own files prove the copy came from it:
a copy from a replaced repository, or one an in-repo alias placed, is left
to the record, as before.

* fix(docs): keep a file of the member's at a path the team never had (#993)

The docs mirror pruned any local file at a path the team history never
had, so a member's own notes there were deleted on the next pull. Having
no team version is no proof that teamai put a file there: the prune now
removes only teamai's copies of docs the team removed, and leaves the
member's own files without a word. doctor stops calling them stale, and
the usage guide no longer says local drafts are removed. Earlier tests that
expected such files pruned now use team docs the team removed.

* docs(designs): the docs mirror prunes only teamai's copies of removed docs (#993)

* fix(rules): remove a rule copy that holds what pull writes today (#993)

`remove rules` deleted a tool's copy only on the checkout's record or the
team history. A checkout without history and without a record, such as a
resource cache, left teamai's own copy behind. The copy that holds today's
render of the team rule, or the rule verbatim, is now teamai's too, read
before the team file goes, as `remove skills` already does.

* fix(source): keep a source skill copy holding the member's file, on record (#993)

A copy pull kept whole because the source added a file where the member
had one was left off `installedSkills`, so `source remove` forgot it and
dropped the manifest. It stays on record now. Removal then tells the
source's files from the member's by their bytes, not their path, and a
file at a path the skill never had keeps the directory even when no file
of the source is left in it.

* test(local-agent): uninstall_rule removes the installed copy and keeps the member's (#993)

* fix(mcp): tell another tool's server by the file's real path (#993)

An entry another tool's record claims in the same file is left to that
tool. The file was compared by name, so a tool whose MCP path links to
another's file adopted that tool's server once its own record was lost,
and a later removal for it would take the server from the shared file.
It is compared by real path now, as the rest of MCP reconciliation does.

* fix(source): keep a source skill copy whose every file the member edited (#993)

Removal kept a copy holding an edited file only when another file still
matched the source. A copy whose every file the member edited, such as a
single SKILL.md, was deleted by `source remove` or by a pull after the
source withdrew the skill. When the source history has the skill, a file
that matches no source version keeps the copy.

* fix(ownership): keep a member's entry of the other type at a delivered path (#993)

A file where a team skill directory goes, or a directory where a rule or
agent file goes, was not judged the member's. Delivery over it failed, yet
the pull could record the team revision as synced, so once the member
removed the entry a plain pull delivered nothing until the team changed or
the member ran `pull --force`. Such an entry is now kept and named, and the
next pull after it is gone delivers the team version.

* fix(skills): keep a member's file when removing a skill with a built-in's name (#993)

`teamai remove skills <name>` deleted the whole directory when the name is
one the CLI once shipped (such as team-wiki-codebase), so a file the member
added beside it was lost. It now removes the files the team history proves
teamai's and the files a release packaged there, as uninstall does, and keeps
and names a directory left holding anything else.

* fix(skills): keep a delivered skill when the team adds a file where the member has a directory (#993)

When the team added a file to a delivered skill at a path where the member
had a directory, or a directory where the member had a file, the copy failed
inside pull and the pull still advanced to the team revision, leaving the
skill stale. Such an entry is the member's now: the skill is kept whole and
named, as when the team adds a file where the member has one.

* fix(local-agent): tell CodeBuddy's user MCP file by its real path (#993)

With `~/.codebuddy/.mcp.json` linked to `mcp.json`, a record an older agent
wrote without a file resolved to `mcp.json`, while the install target was
`.mcp.json`. The record was dropped as another file's, so reinstalling the
same server failed as "not managed by teamai". Records are matched to the
target by real path now, as MCP reconciliation does.

* fix(skills): deliver a skill around a member's entry of the other type, and retry failed copies (#993)

A member's directory where the team adds a file to a delivered skill, or a
file where it adds a directory, is the member's: it is kept and named, the
files it covers stay undelivered, and the rest of the skill is delivered.
The pull is not counted as synced, so the next one is a full sync that
delivers those files once the entry is gone.

A skill, rule or agent copy that fails with an error no longer counts as
synced either: the revision stays, and the next pull retries it.

* fix(source): keep a source skill copy when the source adds a directory where the member has a file (#993)

A file of the member's where the source now has a directory was missed by
the collision check, so the copy failed on the conflict instead of keeping
the member's copy. An entry of the other type at a source path, or at one of
its parents, is the member's now, also when the source cache has no history.

* fix(uninstall): keep OpenCode's user plugin on a project uninstall (#993)

The plugin in the user's OpenCode config now also carries the team rules and
instructions for V2, for every project and the user scope. A project-scope
uninstall removed it, which ended delivery for every other project. It keeps
it now and names it, as it does for the other global adapters; a user-scope
uninstall still removes it, and so does `teamai hooks remove`.

* fix(skills): keep a skill directory holding a git repository of the member's (#993)

The ownership check reads a skill directory's files the way delivery lists
them, which skips `.git`. A recorded skill whose files matched was then
deleted whole by `teamai remove`, `uninstall` or a source removal, taking a
repository the member made in it, with its local commits. A directory holding
a `.git` is never deleted whole now; only teamai's files in it go.

* fix(local-agent): keep a CodeBuddy server copy the member edited when moving it (#993)

When CodeBuddy's preferred user MCP file changed, reinstalling a server
moved it there and deleted the copy in the old file without checking it.
A copy the member edited since teamai installed it is kept now, and named.

* fix(local-agent): keep a CodeBuddy server's record until its move is written (#993)

Moving a server to the user MCP file CodeBuddy now reads recorded it in the
new file before writing it there. When that write failed, the record pointed
at the new file while the server stayed in the old one, so uninstall left it
installed. The old file's record is existing ownership now: it stays until the
new file is written, as for a server already recorded in the target file.

* fix(uninstall): record a project's OpenCode exclusion when its plugins are kept (#993)

A project-scope `uninstall --agent opencode` keeps the user plugin
directory, so with only a server-pushed agent-hook plugin there the plan was
empty: it said "Nothing to uninstall" and never excluded OpenCode, whose
global hooks stayed active for the project. OpenCode now takes the
exclusion-only path, like the other tools with global channels, and the kept
agent-hook plugins are named with the other retained adapters.

* fix(docs): keep a member's doc that has the bytes of a team doc that is a link (#993)

A regular file of the member's at the path of a team doc that is a link was
taken for teamai's copy when its bytes matched the link's target, and the
mirror replaced it with the link. Equal bytes now prove a copy only of a team
file; such a doc is the member's unless the team history has that file.

* fix(mcp): judge each CodeBuddy lookup file with the tools that write it (#993)

Cleaning CodeBuddy's other user MCP files reused the ownership check built for
the file it reads, which knew of no other tool writing there. With
`~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json`, Claude's copy of a
team server matched CodeBuddy's render and was deleted through the link. Each
file is now judged with the records of every tool whose MCP file it is.

* fix(docs): judge a deactivated namespace's doc by its type as well as its bytes (#993)

When a docs namespace stops being active here, its copies that are teamai's
are removed. A member's regular file whose bytes equal the target of a team
doc that is a link was taken for teamai's, through the link and through a
history check that also matched link versions, and deleted. A file is
teamai's only as a team file now, today's or one from the history, as on
every other docs path that deletes or replaces (prune, copy, uninstall).

* fix(mcp): count another tool's servers in a shared file even when it is not detected (#993)

Whose entries a config file holds was judged only with the tools detected on
this run. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json` and
Claude no longer installed, a CodeBuddy pull took Claude's identically rendered
server for its own and deleted it through the link, leaving Claude's record
stale. Every tool that maps a file counts now, detected or not: detection
decides where teamai writes, never what it may delete. Doctor judges the same way.

* fix(mcp): restore a file two tools write through linked paths once, to its first state (#993)

Configs written during an MCP sync are restored when ownership cannot be
saved. Snapshots were keyed by path, so a file two tools reach through
linked paths got one per path and the later one, taken after the first
tool's write, was restored last: the server stayed without its record. One
snapshot per real file now, taken before its first write.

* fix(coauthor): leave a settings file that does not parse as it is (#993)

Project scope writes the co-author setting to `.claude/settings.local.json`,
the member's own file. One that did not parse was read as empty, so a pull
replaced the whole file with teamai's `attribution` block and the member's
settings were lost. A Claude or Cursor file that is not a JSON object is now
left untouched and reported as skipped, and the setting is written on the
first pull after the member repairs it.

* fix(hooks): leave a hook settings file that does not parse as it is (#993)

Every hook writer read its settings file as empty when it did not parse, so
a pull or a session-start pass while the member was mid-edit of, say, their
HOME `~/.claude/settings.json` replaced the whole file with teamai's hooks.
The Claude, Cursor, Copilot, Codex and ZCode writers, and the agent-hook
install and removal, now leave such a file byte-identical and say which file
and why; nothing is recorded, and the first pull after the repair writes it.

* fix(docs): never walk a docs mirror that is a link of the member's (#993)

When `sharing.docs.localDir` itself was a link to a personal directory,
uninstall walked it and deleted the files behind it that matched a team doc
from the history, outside the mirror. A mirror root that is a link is now the
member's on every path: pull copies, prunes and withdraws nothing through it
and names it, doctor reports it, and uninstall leaves it and what it points at.

* fix(uninstall): keep the record of hooks a settings file that does not parse still holds (#993)

Uninstall left out a hook settings file that did not parse, then deleted the
data directory holding the record of its hooks; once the member repaired the
file, teamai's hooks ran there with no record left to remove them by. Such a
file is now tried, named and left as it is, and the data directory stays, so
uninstall run again after the repair removes them. `source remove-http` and
uninstall keep the record of an HTTP agent hook they could not remove, and the
local agent's directory with it, instead of clearing the whole manifest.

Pull also names a docs mirror that is a link before counting or previewing
its files, so it never walks it, on a dry run either.

* fix(uninstall): report an uninstall that left hooks in place as incomplete (#993)

When a hook settings file could not be cleaned, uninstall kept the data
directory but still said "teamai uninstalled" and exited 0, and a targeted
uninstall of the last tool did not record the tool as excluded, so the hook
left in place could sync its resources back. Hooks left in place now make the
uninstall incomplete, as an OpenCode entry it could not remove does: exit code
1, the files named, and the tool excluded. The message for a settings file that
does not parse no longer points at `teamai pull`.

* fix(uninstall): stop hooks an incomplete uninstall left in place from syncing back (#993)

A full uninstall that could not remove a tool's hooks kept the data directory
with every tool still enabled, so the hook left in place ran a session-start
pull that restored what had just been removed. Each tool whose hooks remain is
now excluded, and the session-start pull of an excluded tool's hook does
nothing, as its instruction and HTTP handlers already did, until the uninstall
is run again after the repair.

* fix(local-agent): keep removed HTTP sources disabled (#993)

* fix(local-agent): serialize HTTP sync and source removal (#993)

* docs(http): describe skipped concurrent hook syncs (#993)

* fix(uninstall): remove the HTTP source under its lock before teardown (#993)

A full user-scope uninstall removed agent hooks, plugins and ~/.teamai
without the local-agent lock, so a hook sync paused after loading its
config could reinstall resources once uninstall reported success.
Uninstall now runs the remove-http shutdown first: it waits up to 30
seconds for the lock, disables the source before teardown, and exits 1
without removing anything when the lock cannot be taken.

* fix(local-agent): let a server-pushed uninstall hold its sync's lock (#993)

A sync holds the HTTP source lock while it runs an uninstall_teamai
command, so the uninstall it spawns waited for that lock until the hook's
15-second budget killed the sync, unacknowledged and with teamai still
installed. The sync now passes its pid to the command; a child whose
parent holds the lock runs under it without acquiring or releasing it.

* docs: point the kept-edit links in sharing at member-guide's Auto-sync

The Auto-sync section moved to member-guide in the docs reorganization,
so the in-page anchors #993 added no longer resolve.
jeff-r2026 pushed a commit that referenced this pull request Oct 9, 2026
…915) (#1000)

* test(agents): record the stale Kiro .md sibling as teamai's in the fixture (#993)

* fix(docs): keep a member's entry whose type conflicts with a team doc (#993)

The mirror replaced a local directory at a team doc file's path, or a
local file at a team docs directory's path, and deleted the backup with
its staging directory. membersDocs now treats such an entry as the
member's unless the team history proves it teamai's (a file where the
team now has a directory, or a directory whose every file is a team
version), and copyDocs leaves a kept entry in place. A link is still
replaced without touching its target.

* test(skills): expect the not-teamai's line for a member's copy of a removed skill (#993)

* refactor(git-exclude): one module owns git exclude blocks, mcp-exclude moves onto it (#915)

src/git-exclude.ts owns info/exclude files: sync (replace), ensure
(add-only gate with a per-path result), remove (owner or all, with a keep
predicate) and report. Owners are named [a-z0-9/_%-] with per-owner
markers; mcp-exclude keeps #886's markers, trimmed parsing and its
already-ignored rule, and ensureExcludedFromGit maps ensure's results to
GitExclusion unchanged. Lines route to the repository a path lands in
(submodule, nested clone, symlink target), use the on-disk spelling, NFC
only with core.precomposeunicode, and refuse line breaks and trailing
spaces. Each owner's exclude files live in a caller-supplied record;
stateHomeRecord keeps them in <stateHome>/git-exclude.json. git children
drop the repository variables a hook exports.

The re-including rule's source is now resolved from the toplevel, where
git names it, not from the file's directory: a rule in .claude/.gitignore
was named as .claude/rules/.claude/.gitignore.

* fix(docs): keep a member's link at a team doc's path, without following it (#993)

membersDocs skipped links, so the mirror still moved a member's link
aside and deleted it with the staging directory (rest of the Codex P1).
A link at a doc's path, or at an ancestor of one, is now the member's
unless the team has the same link there: it is kept, named and never
followed, and the docs under it are not delivered.

* ci: re-run checks after an unrelated recall-attribution flake (#993)

Node 22 on macOS failed on 9309e201 with one unhandled rejection from
src/__tests__/recall-attribution.test.ts (a votes-lock write outliving the
test's temp HOME); every test passed and #995 does not touch that code.
The fork cannot re-run jobs, so this empty commit re-triggers CI.

* fix(git-exclude): never take an unreadable exclude file for empty (#915)

updateFileLocked read through readFileSafe, which turns every read error
into empty content: an info/exclude that exists but cannot be read was
rewritten with only teamai's block, dropping the member's lines, and the
ensure that gated a resolved MCP value reported success.

A read failure other than ENOENT/ENOTDIR now throws NotReadableError and
writes nothing. ensure returns notReadable {path, error, reason, fix};
sync and remove report write kind notReadable and keep the file in the
owner's record; ensureExcludedFromGit maps it to failed, so the secret is
withheld. A missing exclude file is still created.

* feat(git-exclude): keep delivered skills out of git behind sharing.gitExclude (#915)

sharing.gitExclude.enabled (default false; init writes true into a new
git-mode or single-repo teamai.yaml) and the partition override
gitExcludeEnabled resolve as override, team, false.

pull() carries one DeliveryRecorder for the project scope. Writers report
landed paths per writer id and say whether they succeeded or failed; the
skills handler is the first (through the pull's ledger, so the local
agent's ledger-less calls report nothing). After pullSources, still under
the partition sync lock and skipped for a contended scope, pull merges the
reports into the checkout record's gitExcludePaths (fast path add-only;
full sync replaces writer by writer, a failed writer keeps its previous
entries still on disk) and syncs teamai's delivered block in the clone's
info/exclude, or removes only that block when the flag is off. The list is
kept whatever the flag; awaitingFullSync and the complete-branch rebuild
carry it, and a record without it misses the fast path, so the first pull
after upgrading from 0.26.0 is a full sync. The delivered owner's exclude
files are recorded in the partition state (gitExcludeFiles).

* feat(git-exclude): keep everything a pull delivers out of git (#915)

Every writer of a project-scope pull now reports what it wrote, or
confirmed as teamai's, to the delivery recorder, and says whether it
delivered all it meant to:

- rules, one file per line (namespace subdirectories, flattened names,
  Copilot instructions), also from the fast path's rewrites;
- agents, plus a copy a tool holds for its model while the record still
  matches it;
- the built-in teamai skill and the teamai-recall rule and agent;
- the owned teamai-context files that hold teamai's blocks;
- the main checkout's .claude/settings.local.json while its hook manifest
  records team hooks there, and Copilot's .github/hooks/teamai.json while
  it holds teamai's entries, read from disk so an unresolved team hook
  set or Copilot's built-ins-installed skip still lists them;
- .claude/settings.local.json while the co-author record says teamai
  wrote the empty trailer and the file still holds it;
- source skills this pull landed, never the manifest's entries. No
  configured source is a successful empty result (the copies stay on
  disk, visible); a source left as it was keeps its previous lines.

A copy pull keeps for the member's edit is never reported, so it leaves
the block. Collisions and failed writes mark their writer failed.

The acceptance fixture (roles, a project, a source, recall, team hooks,
MCP with and without a resolved value, Copilot plus six agents, a Codex
copy in .agents/skills, a tracked SKILL.md with a new team file) checks
git status against an allowlist of paths later tickets move, after init,
pull and a session start, with the member's files visible and addable.

* ci: re-run the Codex PR review, cancelled at its time limit (#993)

* fix(mcp): save the manifest when an adoption records a server it did not rewrite (#993)

An unrecorded server equal to today's team render was adopted into the
in-memory records, but with no file write the manifest was not saved, so
a later team removal or uninstall found nothing owned and left the server
installed. The reconcile now saves the manifest whenever this run changed it.

* fix(mcp): preview a no-op MCP adoption on --dry-run, and cover uninstall (#993)

A dry run now says it would record an unrecorded server that already holds
the team's render, in .mcp.json-style files and in Codex config, and still
writes nothing. The real-CLI cases add the dry run before the adoption and
an adoption followed by teamai uninstall; both failed before b62e503b.
Hook adoption needs no change: reconcileHooks saves its records whenever
they differ from the previous ones, whether or not it wrote a file.

* feat(git-exclude): give Copilot the team instructions from a file teamai owns (#915)

With sharing.gitExclude on, Copilot's project culture, shared-instructions and
recall blocks go to .github/instructions/teamai-context.instructions.md with
applyTo: "**", which the delivered git exclude block lists. The next pull
strips teamai's blocks from .github/copilot-instructions.md and deletes it only
when teamai created it and git does not track it. Turning the flag off moves
the blocks back on the next pull. Doctor checks the new file through the
existing instruction checks; the usage guide names the Copilot surfaces that
read no .github/instructions file and the chat.includeApplyingInstructions
dependency.

* fix(docs): keep a member's directory where the team removed a doc file (#993)

When the team deleted a doc file the member had replaced with a directory,
the prune walked into that directory and deleted its files, which match no
team version. A directory at a path where the team history had a doc file,
holding anything that is not a team version, is now kept whole and named.

* feat(git-exclude): keep tracked copies a removal would delete, and sweep Codex's shared skills (#915)

When a delivered copy stops being delivered (role or project switch, a root
skill once roles are set, a team tombstone or removed rule, a source dropping
a skill), the removal pass now keeps a path the checkout's index tracks and
names it once per pull with the git rm -r hint, so a later pass that proves
the copy teamai's still leaves it. The inactive-namespace, stale-skill and
tombstone sweeps also visit Codex's .agents/skills/<name>, deleting a copy
only when judgeRemoval returns remove and naming an edited or member copy.

* feat(git-exclude): keep self-mode team hooks out of the tracked settings (#915)

In a single-repo team with sharing.gitExclude on, Claude Code's team hooks
go to each checkout's own .claude/settings.local.json, which the delivered
block lists, and the committed .claude/settings.json keeps only the
built-ins, so a fresh clone still has them and a pull leaves git status
clean. The per-checkout hook index records the team hooks of the one file
that holds them. Turned off, the next pull moves them back into the tracked
settings. init . syncs the delivered block after its own writers, since it
ends without a pull. hooks remove and uninstall also clear settings.local.json.

* fix(mcp): remove an unrecorded copy of a server the team deleted (#993)

With a lost manifest, a server an older release installed stayed for good
once the team deleted it: only today's names were judged, and with no team
server and nothing owned the reconcile returned early. Pull and uninstall
now also remove an unrecorded entry, in .mcp.json-style files and Codex
config, that equals a render of that server from the team history. A
member's entry under that name stays. The early return now also requires a
team history with no MCP server.

* feat(git-exclude): one delivered block for every live checkout, and other repositories' paths where git reads them (#915)

A pull now lists in teamai's delivered git exclude blocks the union of the
lists of the project's live checkouts (liveCheckoutRecords, now exported),
so a pull in one worktree no longer drops another worktree's lines, also in
a --separate-git-dir repo and in a project that is a submodule. A removed or
pruned worktree's lines go on the next pull, fast path included.

A delivered path that holds a file of the member's in another live checkout
(not in that checkout's list) gets no line, since the shared line would hide
that file too; pull names it. A checkout with no list yet has no such files,
and the member's own .claude/settings.local.json in a linked worktree is
never one. A path tracked in one checkout and untracked in another stays
listed.

Paths that land in another repository (a tool folder that is a submodule or
a nested clone, a tool home under version control) go to that repository's
exclude file, in a delivered/<id> block named after the partition, so the
superproject stops showing the submodule modified and projects sharing a
tool home each manage only their own block. Turned off, both blocks go.

* perf(sync): read the team's MCP history once per pull, in one git call (#993)

historicalContents read each historical blob with its own git process
(about 370 ms for 60 versions); it now reads them all with one
git cat-file --batch (about 30 ms). The MCP reconcile reads that history
once per run for every target, and only when a target file holds an
unrecorded entry; with no team server, nothing recorded and no server in
any target file it returns without reading it. A dry run names an
unrecorded copy of a server the team deleted that a pull would remove.

* fix(docs): keep a member's link where the team deleted a doc (#993)

The prune unlinked any link at a removed team doc's path. A link there is
now removed only when it is a version of that doc from the team history
(git stores a link as a blob of its target); a link of the member's stays,
and is never followed. A link at a path the team never had is pruned as
before.

* fix(coauthor): keep the record of a shared settings file that does not parse (#993)

A pull that could not read a recorded shared settings file dropped its
record, so once the member repaired the file no later pull removed the old
attribution value. The record now stays until the file can be read.

* fix(docs): keep any non-file entry of the member's where the team deleted a doc (#993)

A link at a removed team doc's path goes only when it is a version of that
doc from the team history; any other entry that is not a regular file stays
and is named. Adds the real-CLI case for a member's link replacing a doc.

* fix(sync): never adopt or write through a link at a delivered file's path (#993)

isTeamaiCopy and the copy judgement followed links: a link at a rule or
agent path whose target held an older team version was taken as teamai's,
and the next pull wrote through it into the member's file. A link in place
of a delivered file is now the member's (kept, named), a link there is
never removed as teamai's copy, and a skill directory holding a link is not
teamai's copy, since copying over it would write through it.

* fix(docs): keep a member's directory of links where the team deleted a doc file, and do not call it stale (#993)

A directory that replaced a former team doc file and held only links read
as teamai's (links are not listed), so the prune removed it. Such a
directory is now the member's whenever it holds a link. doctor also stops
listing files inside a directory pull keeps whole as stale docs.

* fix(hooks): recognize unrecorded team hooks in every format, on pull and uninstall (#993)

Pull recovered a team hook entry whose record was lost only in Claude and
Codex files. In Cursor, Copilot and ZCode files the old entry stayed as the
member's and the new one was added beside it, so both ran. Each entry is now
compared with the team's hooks rendered the way that format's reconciler
writes them; one equal to exactly one hook is replaced or removed as teamai's.

Uninstall did not judge unrecorded entries at all, so with the records lost
it left custom team hooks installed, and missed a main-checkout Codex file
holding only team hooks. It now judges every hook file against the team's
hook history, gated to the project as pull renders them in a non-self
project scope, and removes the entries equal to exactly one team hook.

* fix(sync): treat any non-regular entry in a skill or docs directory as the member's (#993)

A directory is teamai's copy only when every entry is a regular file that
is a team version; a link or any other non-regular entry anywhere below it
makes it the member's. Adds real-CLI cases for a skill directory holding a
link and a docs directory of links where the team deleted a doc file.

* fix(sync): keep a member's link at any delivered path (#993)

A link at a delivered skill directory, rule or agent file, docs entry or
source skill destination is the member's: teamai never creates links, so
it is never adopted, replaced, written through or deleted, on pull,
remove and uninstall alike. Pull and doctor name it: "Kept <path>: it is a
link of yours, so teamai does not replace it. Remove the link to receive
<name> from the team."

This deliberately changes earlier behavior: source and team skills used to
replace a leaf link in place of a skill directory with a copy, and the
Codex duplicate cleanup deleted a configured leaf link. The source tests
that asserted those now expect the link kept.

* fix(git-exclude): report an unreadable exclude file as such, and ask git a fixed number of times per checkout (#915)

* fix(agents): prove a tombstoned agent copy teamai's in a team-defined tool too (#993)

For a tool teamai renders no agents for, the tombstone cleanup had no
proof to check and removed any file with the removed agent's name. It now
removes it only when it is a team agent's own bytes from the history; a
member's file of that name stays.

* fix(rules): never read through, write through or delete a member's link at a rule path (#993)

The cleanup and rerender paths judged a rule copy by reading the file a
link points at, so a link whose target held a team render was rewritten
through or deleted: the "Already synced" rerender, the legacy rules
directories (pull and uninstall), the reserved teamai-context name, the
unselected-rule sweep, the moved nested copy, the flat OMP and Kiro
copies, the older .md layout, and teamai remove rules. Each now treats a
link as the member's and leaves it. The empty-directory sweep no longer
deletes a directory whose only entries are links.

* fix(agents): keep a member's link where an agent was delivered, on pull and teamai remove (#993)

The inactive-namespace cleanup judged a copy by the file a link points at, and teamai remove agents deleted the author's root copy by its placement record alone. A link at either path is now the member's and stays.

* fix(skills): keep a skill directory that holds a member's link, on pull, remove and uninstall (#993)

A recorded skill whose file the member replaced with a link to the same
bytes read as unchanged, so pull copied over the link and remove,
uninstall and the tombstone and inactive-namespace sweeps deleted the
directory with every link in it. A skill directory holding a link is now
the member's on pull, named by the link, and kept by every sweep.
teamai remove skills also judges a link at a built-in skill's name
before the name.

* fix(sync): keep an unrecorded file teamai cannot read where a removed resource was delivered (#993)

judgeRemoval counted a file it could not read as absent and removed it. Without its bytes nothing proves it teamai's, so it is now kept as the member's.

* fix(docs): never read through or delete inside a member's link in a deactivated docs namespace (#993)

Withdrawing a namespace's docs read each copy through any link on its path and deleted what matched the team file. A namespace directory the member linked to a folder of their own lost its files, and a linked doc was deleted. A path through a link is now left alone.

* fix(source): keep a source skill copy that holds a member's link, on pull and source remove (#993)

A recorded destination was the source's whole, so pull copied over a link the member put inside it and source remove deleted the directory with it. Such a copy is now the member's: pull names the link and source remove keeps the directory on record.

* fix(local-agent): keep a member's link where the local agent installs a skill or rule (#993)

The local agent installs through pullItem with no delivery record, which skipped every ownership check: a rule was written through a link and a skill directory link was replaced. A link at the path, or inside the skill directory, is now kept and named.

* fix(sync): deliver team and source skills without the links in them (#993)

Copying a skill recreated each link in its source as a link in the
member's tool directory, which then read as the member's own, and let a
skill plant a link to any target. Delivery now skips a link in a team or
source skill, without following it, and names each one once:
"Skipped <link> in <skill>: teamai does not deliver links."

* fix(docs): keep a member's link in the docs mirror at a path the team never had (#993)

This deliberately changes the mirror prune: it used to unlink any link at
a path the team repo's history never had, as a local leftover. teamai
never creates a link there, so such a link is the member's: pull now
keeps it and names it, and doctor no longer reports it as a stale doc.
A link matching a team link once at a removed doc's path is still pruned.
The docs and doctor tests that expected the unlink now expect it kept.

* feat(git-exclude): doctor checks the delivered blocks, pull --dry-run previews them, background pulls keep what they could not say (#915)

* docs(designs): state that teamai never follows, replaces or deletes a member's link (#993)

* fix(uninstall): remove only teamai's docs from the docs mirror, and keep the member's (#993)

Uninstall deleted the whole docs directory, so a file pull had kept as
the member's, a directory or a link of theirs went with it. It now
removes a file or link only when it is a version of that doc from the
team repo's history, names everything else, and removes a directory only
once it is empty. While the history cannot be read, the mirror stays
whole. A mirror inside the data home that keeps something survives the
removal of that home with it.

* feat(removals): never delete a file git tracks, in layout migrations and explicit commands (#915)

Layout migrations (a rule's legacy .md and superseded copy, legacy rule
directories, moved nested copies, agent format siblings, the Codex
configured-copy dedupe, leftover files of another skill version) keep a
tracked old copy and say where the resource lives now. teamai remove,
source remove and uninstall remove the rest and name each tracked path;
uninstall's summary lists them under Kept (tracked).

A team repo with no rules left now runs the rules sweep, so the copies of
a last rule the team deleted go, on the same ownership proof.

Test names and comments describe behaviour instead of how the work was
split.

* test(commands-reference): load the command table in a fresh module registry (#915)

The test now imports the CLI entry after vi.resetModules() and restores
the env stub after it, so it reads the table under its own guard whatever
another file in the same worker loaded or stubbed.

* fix(mcp): write a linked MCP config through its link, and keep its target out of git (#993)

A member's MCP config can be a symlink into a dotfiles repository. The
atomic writes replaced the link with a regular file. They now write at the
file the link points to, rollbacks included, and a linked CodeBuddy
~/.codebuddy/mcp.json is kept rather than deleted.

A resolved value then lands in the link's target, so the git checks judge
that file in its own repository: a target that repository tracks gets no
value, and an untracked one is listed in that repository's info/exclude,
which pull and uninstall now find to take the line out again.

* fix(docs): name a kept link at a removed doc's path as before, and only a never-team path's link as such (#993)

Goes with keeping a member's link at a path the team never had: a link at a removed doc's path keeps the line that says the team removed it.

* test(skills): expect pull to name the member's link inside a skill directory (#993)

* feat(git-exclude): doctor notes the copies pull keeps because the repository tracks them; document the checks, the preview and background pulls (#915)

* fix(opencode): keep a member's linked opencode.json and openclaw.json as links (#993)

The OpenCode instructions writer and the OpenClaw hook entry writer
replaced a symlinked config with a regular file, and uninstall deleted a
linked .opencode/opencode.json left holding only its $schema. Both now
write at the link's target, and a link is never deleted.

* fix(rules): never delete a linked CLAUDE.md or AGENTS.md when its teamai section goes (#993)

Removing teamai's section from an instruction file teamai created deleted
the file once nothing else was left, a member's link to it included. A
link stays; its target is emptied instead.

* fix(skills): push no links to the team repo, and name why remove and uninstall keep a skill directory (#993)

A push copied a link inside the member's skill into the team repo, so every
member then received a skill teamai will not deliver; push now skips each
link and names it. remove and uninstall now say a kept skill directory is a
link of the member's, or holds one, instead of claiming it has no delivery
record.

* fix(git-exclude): report a delivered path a rule re-includes as a failed sync (#915)

* fix(source): keep reading a record into a removed OpenClaw workspace, and never another repository's history (#993)

A source record of a copy in an OpenClaw workspace outside ~/.openclaw was
rejected once that workspace was gone, so every pull and source remove of
that source failed. The configured workspace now counts as a tool home even
when its directory is missing. A source cache without its own .git no
longer lets git read an enclosing repository's history (a dotfiles HOME):
only today's source proves a copy then.

* feat(git-exclude): a checkout on the un-migrated layout does not decide the setting with its own gitExcludeEnabled (#915)

* feat(removals): keep tracked files in the built-in skill prune, and cover every tracked-copy guard through the real CLI (#915)

* fix(source): leave a copy in a tool home that moved, instead of rejecting the record (#993)

Configuring OpenClaw to another workspace made the record of a copy in the
old one invalid, so every pull and source remove of that source failed.
The record now notes the tool home of each absolute destination; one whose
home moved is dropped from the record and its copy left as it is, while an
absolute path the record cannot account for is still rejected.

* fix(docs): keep the member's hidden files on uninstall, and match a link only to a team link (#993)

uninstall did not count hidden entries in the docs mirror as kept, so with
nothing else kept it deleted the data home around them. They are now named
and keep the mirror. A link in the mirror is now teamai's only when the
team history had a link with that target there, not a file holding the
same text (the history now carries each version's git mode).

* feat(uninstall): remove every teamai git exclude block after the files it hid; --agent drops only that tool's lines (#915)

Uninstall reads the exclude files the project's delivered owners recorded,
plus the project's own and the MCP ones, before it deletes anything, and
under the partition's sync lock removes every teamai block there once the
files are gone and before the partition state goes. Another project's
delivered block in a shared repository stays. An MCP line stays while a
config in any checkout of the repository may hold a resolved value, the
main checkout of a --separate-git-dir repo included; a credentials line
stays while its file is there. A read-only exclude file is named with the
lines to delete by hand; a repository that is gone is skipped.

uninstall --agent drops the tool's paths from every checkout's list,
keeping a path another tool in use reads, and syncs the blocks again.
uninstall --dry-run lists the blocks by owner and file.

HTTP mode: a skill the local agent installed under its SKILL.md name
(dir_name in its manifest) is now removed too.

* fix(git-exclude): keep a recorded block when a path cannot be placed (#915)

When git cannot say which repository a path lands in (sync reports it in
gitFailed), its line may sit in any exclude file the owner recorded. A
replace in that run emptied such a block and dropped the file from the
owner's record, removing protection while reporting the failure.

While any path fails placement, sync now only adds: every block keeps its
lines, every recorded file stays recorded, and the paths that placed are
still listed. The next sync in which every path places replaces as before.

* fix(git-exclude): treat an unverified exclusion as a failure (#915)

After writing a path's line, ensure counted every answer of
`git check-ignore` except "would commit" as excluded. A check that timed
out or failed in any other way therefore let the MCP reconcile write a
resolved value into a file git was never shown to ignore.

Only a verified "ignored" now counts as excluded; any other answer is
gitFailed, naming git's error and the check to repair. The exclude file
stays recorded, as its line was written. ensureExcludedFromGit maps this
to its failed result, so the value is withheld.

Two MCP exclude tests asserted the old reading and now expect the
failure: git failing check-ignore while ls-files answers, and a path
beyond a dangling directory symlink, which git refuses to check.

* fix(skills): keep a delivered skill directory that holds a file the member added (#993)

A skill directory with any file on record was taken as teamai's whole, so
remove, uninstall and the tombstone cleanup deleted a file the member had
added beside teamai's. A recorded directory now goes only when every file in
it is on record or a team version; otherwise it is kept whole and named.
Source skill removal applies the same rule against the source's files and
history.

* docs(mcp): a resolved value waits for git to confirm the exclusion (#915)

* fix(skills): remove and uninstall take only teamai's files from a skill directory (#993)

When a delivered skill directory holds a file the member added, remove and
uninstall now delete only the files on record or matching a team version,
name each file they keep, and leave the directory while anything is left in
it. uninstall plans the exact files, so its dry run shows them.

* fix(source): keep a member's file at the path of a link the source skill has (#993)

Delivery never copies a link from a source skill, but removal counted any
history at that path as the source's, the link included, so a regular file
the member put there was deleted with the skill. Only a regular file the
source has, or had, is the source's now.

* fix(mcp): find teamai's CodeBuddy servers in every user MCP file, and treat a linked lookup path as its target (#993)

A server an earlier teamai left in a CodeBuddy user MCP file that CodeBuddy no longer reads stayed there once its record was lost: pull and uninstall looked only at the file CodeBuddy reads, and returned early when that file was empty. They now also look at the other lookup files that exist and remove an unrecorded entry equal to a current or historical team render; a member's entry stays.

Lookup files are compared by real path. With ~/.codebuddy/.mcp.json a symlink to ~/.codebuddy/mcp.json, a pull recorded the server at the link and then removed it from its target, the same file. With ~/.codebuddy.json a symlink to ~/.codebuddy/mcp.json, the upgrade move emptied and deleted the file the link points to.

* fix(git-exclude): name the check to run when git cannot confirm an exclusion (#915)

When git cannot confirm that it ignores a file after teamai lists it,
the value stays withheld. The message now quotes git's error and names
the next step: check that `git check-ignore -v <path>` works in that
repository, then run the command again.

The two MCP exclude tests changed in the previous fix asserted that an
unconfirmed exclusion still let the resolved value be written, which is
the behaviour fixed there; one of them now asserts the full warning.

* fix(git-exclude): keep both delivered blocks when a path's repository cannot be located (#915)

Pull splits delivered paths between the project's `delivered` block and
`delivered/<id>` by the repository git names for each. A path whose
repository git could not locate (a tool folder whose `.git` points to a
deleted git directory) went to `delivered/<id>` only, so the `delivered`
sync replaced its block and dropped the path's line, although git still
reads that line from the project's own exclude file.

Such a path now goes to both syncs. Each reports it as not placed and
keeps every line its block holds; pull warns once per path.

* fix(rules): keep a tracked teamai-context rule copy where nothing rewrites that path (#915)

An earlier release's copy of a team rule named teamai-context is reclaimed at the tool's instruction file, which the instruction sync rewrites right after (Claude, Cursor, CodeBuddy, WorkBuddy). For OpenCode's .opencode/rules and Kiro's steering directory nothing rewrites it, so a copy the business repository tracks is now kept and named, as every other removal keeps one.

* fix(uninstall): name a built-in skill file kept only because it is tracked once, without the member-files warning (#915)

When removeOwnedFiles keeps a packaged file only because the repository tracks it, keepsTrackedCopy already names it. Uninstall no longer adds "holds files TeamAI did not put there. The packaged files were removed" for that directory, as pruneLegacyBuiltinSkills and retireOtherCodexCopy already do.

* feat(mcp): MCP cleanup and the pull-time release reach every checkout of the project (#915)

projectWorktreeConfigs and the pull-time MCP git exclude release now enumerate checkouts with projectCheckouts (live recorded checkouts plus a complete git worktree list, every recorded root when git cannot list them all), moved next to liveCheckoutRecords. From a linked worktree of a --separate-git-dir repo or a submodule, uninstall now removes teamai's servers and their resolved values from the main checkout's MCP config too, and drops the line once it is clean. A pull there no longer releases the shared .mcp.json line while the main checkout's config still holds a resolved value; that checkout's own pull cleans it and releases the line.

Also covers uninstall --agent codebuddy and --agent workbuddy keeping the .codebuddy/rules lines the other tool still reads.

* fix(sync): prove a CONTRIBUTORS file like any other, and a file only by a file the team had (#993)

A skill's CONTRIBUTORS file counted as teamai's by its name alone, so remove
and uninstall deleted the member's own one, and it never made a directory
the member's. It now needs the same proof as any file. A link in the team
history no longer proves a regular file holding the same bytes, and a
version that changed only its mode is kept as its own version.

* fix(skills): keep a delivered skill when the team or the source adds a file where the member has one (#993)

A file the member added to a recorded team or source skill was overwritten
once the team, or the source, delivered a file at that path. Pull now keeps
such a copy whole and names it, as an edited one, unless the file is a
version from the history. A source removal keeps a copy holding a file of
the member's only when the source's own files prove the copy came from it:
a copy from a replaced repository, or one an in-repo alias placed, is left
to the record, as before.

* fix(docs): keep a file of the member's at a path the team never had (#993)

The docs mirror pruned any local file at a path the team history never
had, so a member's own notes there were deleted on the next pull. Having
no team version is no proof that teamai put a file there: the prune now
removes only teamai's copies of docs the team removed, and leaves the
member's own files without a word. doctor stops calling them stale, and
the usage guide no longer says local drafts are removed. Earlier tests that
expected such files pruned now use team docs the team removed.

* docs(designs): the docs mirror prunes only teamai's copies of removed docs (#993)

* fix(git-exclude): keep the blocks when the team setting cannot be read (#915)

With the team's teamai.yaml missing or invalid and no member override,
sharing.gitExclude fell back to off, so pull removed the delivered blocks
and git add -A staged every delivered resource. In git mode that case is
now unknown: pull and its preview leave the blocks as they are and report
a failed sync (kept by a background pull), and doctor fails with the same
message and names the source "team config unreadable". A member override
still decides, and HTTP mode, which has no team setting, is unchanged.

* feat(local-agent): keep its project skills and rules out of git, and never overwrite a member's file (#915)

The skills and rules the HTTP local agent installs in a project now go in a local-agent block of the exclude file of the repository each copy lands in, while that project's git exclude flag is on (the project's config.yaml, else ~/.teamai/config.yaml). The agent syncs the block after each batch of project installs and uninstalls, lists only workspaces that are live checkouts, and records the exclude files it wrote in ~/.teamai/local-agent/git-exclude.json. Manifest entries record the tools they were written to; an entry from an older CLI counts as recorded where its copy equals the installed version or sits under its dir_name, and gains the field on its next install.

A skill or rule destination the manifest does not record is the member's: the install is not done there and fails with "Kept <path>: it is not teamai's (not in the local agent's records). Rename or delete it; the local agent installs <slug> on its next sync." A copy equal to the download is taken over.

source remove-http drops the block after uninstalling each resource, also when one fails, and teamai uninstall removes it from every exclude file the agent recorded, another repository's included. The instruction targets read the same per-workspace flag.

The HTTP test backend gains rule downloads, the get-config, plugins/config and projects/mine routes, and scope/workspace_path on every command.

* fix(rules): remove a rule copy that holds what pull writes today (#993)

`remove rules` deleted a tool's copy only on the checkout's record or the
team history. A checkout without history and without a record, such as a
resource cache, left teamai's own copy behind. The copy that holds today's
render of the team rule, or the rule verbatim, is now teamai's too, read
before the team file goes, as `remove skills` already does.

* fix(source): keep a source skill copy holding the member's file, on record (#993)

A copy pull kept whole because the source added a file where the member
had one was left off `installedSkills`, so `source remove` forgot it and
dropped the manifest. It stays on record now. Removal then tells the
source's files from the member's by their bytes, not their path, and a
file at a path the skill never had keeps the directory even when no file
of the source is left in it.

* test(local-agent): uninstall_rule removes the installed copy and keeps the member's (#993)

* feat(local-agent): keep model API keys in .codebuddy/models.json out of git, whatever the flag (#915)

Before the local agent writes a model API key into a project's
.codebuddy/models.json, it lists the file in the `credentials` block of
the repository's .git/info/exclude and writes the key only once git
confirms it ignores the file. A tracked file, one a .gitignore rule
re-includes, an exclude file it cannot write, a workspace outside a git
repository or an exclusion git cannot confirm leaves the file as it was;
the task fails with the reason and the fix. The block's exclude files
are recorded in ~/.teamai/local-agent/git-exclude.json.

teamai no longer creates .codebuddy/.gitignore, and deletes the one it
created while it holds only its two lines. An apply_model_config with no
models, and `source remove-http`, delete a models file left with nothing
(never a tracked one or a link), then its line. `teamai uninstall` keeps
a credentials line, with a warning, while the models file still holds a
key in any checkout of the repository.

* fix(local-agent): write the model key outside a repository, and delete only models files teamai created (#915)

A workspace outside any git repository has nothing that could commit
.codebuddy/models.json, so the key is written there as before, with no
git exclude line and no record of one.

An empty model config, or removing the HTTP source, deletes the project
models file only when teamai created it, recorded in the model manifest
in the local agent's state home from now on. A file teamai did not
create stays, and so does its line.

* feat(local-agent): follow each project's git exclude option at every session start (#915)

The local agent's `local-agent` block was only rebuilt after a batch that
installed or removed a project skill or rule, so turning the option on or
off in HTTP mode took effect only when the backend happened to send one.

- Rebuild the block from the manifest and each workspace's option at every
  session start, and after project installs and uninstalls. The agent's
  other runs (prompt, tool call, stop) rebuild it only when the manifest or
  a config.yaml the option is read from changed since the last rebuild,
  judged by a hash kept in the local agent's state home, so they cost no
  git call (about 8 ms per prompt in a one-project measurement, against
  about 30 ms for an unconditional rebuild).
- Keep a failed rebuild in the local agent's state home, under its own
  owner key, so a pull's success never clears it and the reverse. The next
  interactive pull prints it once, and doctor fails a check until a rebuild
  succeeds. Paths no line can name are kept as notices.
- A workspace following a git-mode config whose team teamai.yaml cannot be
  read (and no gitExcludeEnabled) keeps the lines it has, and the agent
  records why; it installs and removes nothing there meanwhile.

* fix(mcp): tell another tool's server by the file's real path (#993)

An entry another tool's record claims in the same file is left to that
tool. The file was compared by name, so a tool whose MCP path links to
another's file adopted that tool's server once its own record was lost,
and a later removal for it would take the server from the shared file.
It is compared by real path now, as the rest of MCP reconciliation does.

* fix(source): keep a source skill copy whose every file the member edited (#993)

Removal kept a copy holding an edited file only when another file still
matched the source. A copy whose every file the member edited, such as a
single SKILL.md, was deleted by `source remove` or by a pull after the
source withdrew the skill. When the source history has the skill, a file
that matches no source version keeps the copy.

* fix(local-agent): say what a refused install or uninstall left undone, and how to fix it (#915)

While a project's flag cannot be resolved, the refusal now names the
resource, the project and the unreadable file, says nothing was written,
and gives both ways out: fix or restore the team's teamai.yaml, or set
gitExcludeEnabled in the project's config.yaml.

* fix(ownership): keep a member's entry of the other type at a delivered path (#993)

A file where a team skill directory goes, or a directory where a rule or
agent file goes, was not judged the member's. Delivery over it failed, yet
the pull could record the team revision as synced, so once the member
removed the entry a plain pull delivered nothing until the team changed or
the member ran `pull --force`. Such an entry is now kept and named, and the
next pull after it is gone delivers the team version.

* feat(pull): keep shared config files that hold only teamai's entries out of git (#915)

A project MCP config without a per-member place (.cursor/mcp.json,
.github/mcp.json, .codex/config.toml, Kiro, OMP, Pi, .workbuddy/mcp.json,
OpenCode's root opencode.json), .codex/hooks.json and OpenCode's
.opencode/opencode.json are listed in the delivered block while git does
not track them and they hold nothing but teamai's entries: no other
top-level key ($schema included), and every server, team hook or
instructions entry one teamai's records own (equal to what teamai writes,
for the OpenCode instructions). They are judged after the MCP and hook
reconciles, so a file written before the upgrade or after its records were
lost qualifies by content.

They are judged on every pull, fast path included, and replace their
previous list (DeliveryRecorder.judgedAll). The first pull that finds an
entry teamai does not own in a listed file takes its line out and says
"<path> now holds entries teamai does not own, so git can see it."; a
background pull keeps it as a git exclude notice for the next interactive
pull.

The usage guide documents the files and the limit when a teammate commits
the same path.

* fix(skills): keep a member's file when removing a skill with a built-in's name (#993)

`teamai remove skills <name>` deleted the whole directory when the name is
one the CLI once shipped (such as team-wiki-codebase), so a file the member
added beside it was lost. It now removes the files the team history proves
teamai's and the files a release packaged there, as uninstall does, and keeps
and names a directory left holding anything else.

* fix(skills): keep a delivered skill when the team adds a file where the member has a directory (#993)

When the team added a file to a delivered skill at a path where the member
had a directory, or a directory where the member had a file, the copy failed
inside pull and the pull still advanced to the team revision, leaving the
skill stale. Such an entry is the member's now: the skill is kept whole and
named, as when the team adds a file where the member has one.

* feat(docs): keep the delivered docs mirror out of git, still searchable (#915)

* fix(pull): say nothing when a shared MCP config with a resolved value takes in a member's server (#915)

Such a file stays in the mcp-exclude block whatever it holds, so leaving
the delivered block does not make it visible, and "git can see it" was
wrong. Also cover a .codex/hooks.json written before the upgrade (no hook
records) and a single-repo team's .codex/hooks.json, which keeps the
built-in hooks and stays visible.

* fix(local-agent): tell CodeBuddy's user MCP file by its real path (#993)

With `~/.codebuddy/.mcp.json` linked to `mcp.json`, a record an older agent
wrote without a file resolved to `mcp.json`, while the install target was
`.mcp.json`. The record was dropped as another file's, so reinstalling the
same server failed as "not managed by teamai". Records are matched to the
target by real path now, as MCP reconciliation does.

* fix(skills): deliver a skill around a member's entry of the other type, and retry failed copies (#993)

A member's directory where the team adds a file to a delivered skill, or a
file where it adds a directory, is the member's: it is kept and named, the
files it covers stay undelivered, and the rest of the skill is delivered.
The pull is not counted as synced, so the next one is a full sync that
delivers those files once the entry is gone.

A skill, rule or agent copy that fails with an error no longer counts as
synced either: the revision stays, and the next pull retries it.

* fix(source): keep a source skill copy when the source adds a directory where the member has a file (#993)

A file of the member's where the source now has a directory was missed by
the collision check, so the copy failed on the conflict instead of keeping
the member's copy. An entry of the other type at a source path, or at one of
its parents, is the member's now, also when the source cache has no history.

* fix(uninstall): keep OpenCode's user plugin on a project uninstall (#993)

The plugin in the user's OpenCode config now also carries the team rules and
instructions for V2, for every project and the user scope. A project-scope
uninstall removed it, which ended delivery for every other project. It keeps
it now and names it, as it does for the other global adapters; a user-scope
uninstall still removes it, and so does `teamai hooks remove`.

* fix(pull): keep a shared config's line while git cannot say it tracks the file (#915)

A teamai-only file git could not answer for was judged `unknown`, and its
entry was replaced with nothing, so its line left the block silently. Only a
confirmed answer replaces the entry now; until then the line stays, the pull
fails naming the file and git's error (a background pull keeps the failure),
and doctor reports the checkout git cannot answer for.

* fix(skills): keep a skill directory holding a git repository of the member's (#993)

The ownership check reads a skill directory's files the way delivery lists
them, which skips `.git`. A recorded skill whose files matched was then
deleted whole by `teamai remove`, `uninstall` or a source removal, taking a
repository the member made in it, with its local commits. A directory holding
a `.git` is never deleted whole now; only teamai's files in it go.

* fix(local-agent): keep a CodeBuddy server copy the member edited when moving it (#993)

When CodeBuddy's preferred user MCP file changed, reinstalling a server
moved it there and deleted the copy in the old file without checking it.
A copy the member edited since teamai installed it is kept now, and named.

* feat(mcp): give Claude the team's project MCP servers in its local scope (#915)

With sharing.gitExclude on, Claude's project servers go to Claude Code's
local scope, ~/.claude.json under projects[<key>].mcpServers, instead of
.mcp.json. The key is the one Claude Code files the checkout under: the
main checkout's real path for it and every linked worktree; in a
--separate-git-dir repository a linked worktree's key is the git
directory, and in a linked worktree of a submodule its directory under
.git/modules. Resolved tokens leave the working tree, and Claude loads the
servers without the approval prompt.

Ownership is shared by every checkout using the key (managed-local-mcp.json
in the project's data home), and uninstall removes exactly teamai's
entries. The next pull moves teamai's servers out of .mcp.json, recorded or
equal to a render in the team's history; keeps the member's own servers,
a teamai server the member changed (named), and the names CodeBuddy still
claims there; and deletes the file when nothing else is left and git does
not track it. Its MCP exclude line goes once no checkout holds a resolved
value. Turning the option off moves the servers back. While tclaude, which
reads .mcp.json, is installed and enabled, Claude keeps the file.

* feat(opencode): deliver team MCP servers through the plugin on OpenCode V2 (#915)

With sharing.gitExclude on, OpenCode V2 and teamai's plugin current, a pull
writes the project's team MCP servers to .opencode/teamai-mcp.json, which the
plugin reads at setup and adds through ctx.mcp.transform for that project
only. The file is teamai's whole, listed in the delivered block, and written
only after the mcp-exclude block holds it when it carries a resolved value.

The pull then takes teamai's V1 entries (recorded or adopted) out of the root
opencode.json and .opencode/opencode.json when git does not track them and
they hold nothing else, and deletes a file left empty. A tracked or mixed
file is left; doctor names it. The plugin is reconciled before the decision,
so a plugin from an older build is replaced and the V1 entries go in the same
pull. Without a current plugin, on V1, or with the option off, the V1 entries
stay or come back and the V2 file goes.

The OpenCode major version is asked once per run. The plugin's project
walk-up also stops at a .opencode/ holding only teamai-mcp.json.

* fix(local-agent): keep a CodeBuddy server's record until its move is written (#993)

Moving a server to the user MCP file CodeBuddy now reads recorded it in the
new file before writing it there. When that write failed, the record pointed
at the new file while the server stayed in the old one, so uninstall left it
installed. The old file's record is existing ownership now: it stays until the
new file is written, as for a server already recorded in the target file.

* fix(uninstall): record a project's OpenCode exclusion when its plugins are kept (#993)

A project-scope `uninstall --agent opencode` keeps the user plugin
directory, so with only a server-pushed agent-hook plugin there the plan was
empty: it said "Nothing to uninstall" and never excluded OpenCode, whose
global hooks stayed active for the project. OpenCode now takes the
exclusion-only path, like the other tools with global channels, and the kept
agent-hook plugins are named with the other retained adapters.

* fix(skills): list a skill delivered around a member's entry file by file (#915)

A skill delivered around a member's entry of the other type was listed in
the delivered exclude block as its whole directory, which hid the member's
entry from git status. Such a skill is now listed one file per line, in
pull and in the --dry-run preview.

* fix(pull): list a delivered skill's files, never its directory (#915)

A skill was listed as its directory (`/<dir>/`), so a file the member added
inside a delivered skill was hidden from `git status` and `git add -A`. Every
writer that delivers skills (team, role, project, source and built-in skills,
Codex's `.agents/skills`, `.github/skills`, the local agent's installs) now
reports the files it wrote, and the git exclude module refuses a directory.

In a skill, a tracked file gets no line and is reported while the skill's
other delivered files keep theirs; a member's negation that re-includes one
of them fails the sync naming the rule. Doctor and `pull --dry-run` report
per file. An older directory line goes on the next full sync, which rewrites
the block.

* feat(hooks): run Codex team hooks from a dispatcher when the project's hooks.json is not teamai's alone (#915)

While sharing.gitExclude is on and a project's .codex/hooks.json is tracked,
holds a member's entry or another top-level key (always in single-repo mode,
whose committed file holds the built-ins), teamai stops writing the team's
Codex hooks into it. The pull that sees this takes teamai's entries, recorded
or adopted, out of the file through its hook manifest and records the
project's hooks in ~/.teamai/codex-team-hooks.json; ~/.codex/hooks.json gets
one `teamai hook-dispatch <Event> --tool codex --team-hooks` entry per event,
timed out at the largest team-hook timeout, before the built-ins. Once the file
is gone or teamai's alone again, the next pull writes the hooks back; turning
the setting off does too.

The new --team-hooks option of the hidden hook-dispatch command resolves the
project from the hook's cwd, evaluates matchers, applies each hook's timeout
(stopping its process group), and passes on stdout and a blocking exit 2. It
exits at once when no project dispatches. The entries name no project, so
their Codex trust holds as worktrees come and go; teamai trusts them like the
built-ins, in single-repo mode too. Uninstall drops the project and the
entries no other project needs.

* fix(uninstall): keep the local agent's lines for the workspaces it still serves (#915)

A project `teamai uninstall` removed every `local-agent` block the agent
recorded, so another project's installs, and a linked worktree's in the
shared exclude file, showed in `git status` until a later session start.
The agent stays after a project uninstall, so its blocks are now rebuilt
from its records without that project's workspace: only this project's
lines go. A user-scope uninstall, which removes the agent, still removes
every block.

* fix(docs): keep a member's doc that has the bytes of a team doc that is a link (#993)

A regular file of the member's at the path of a team doc that is a link was
taken for teamai's copy when its bytes matched the link's target, and the
mirror replaced it with the link. Equal bytes now prove a copy only of a team
file; such a doc is the member's unless the team history has that file.

* fix(mcp): keep a resolved value's own line when only teamai's blocks ignore the file (#915)

A project MCP config that git already ignored got no `mcp-exclude` line. Since
the `delivered` block lists teamai-only configs, that ignore could be
teamai's own: once the member added a server, turned the option off or ran
`uninstall --agent`, the `delivered` line went, and the file holding the
resolved value became visible to `git add -A`.

An ignore from teamai's own blocks no longer counts as already ignored: the
line is skipped only when the rule `git check-ignore -v` names is outside
every `# [teamai:` block. The `credentials` owner already lists every path,
ignored or not; a test now covers that too.

* fix(mcp): judge each CodeBuddy lookup file with the tools that write it (#993)

Cleaning CodeBuddy's other user MCP files reused the ownership check built for
the file it reads, which knew of no other tool writing there. With
`~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json`, Claude's copy of a
team server matched CodeBuddy's render and was deleted through the link. Each
file is now judged with the records of every tool whose MCP file it is.

* fix(docs): judge a deactivated namespace's doc by its type as well as its bytes (#993)

When a docs namespace stops being active here, its copies that are teamai's
are removed. A member's regular file whose bytes equal the target of a team
doc that is a link was taken for teamai's, through the link and through a
history check that also matched link versions, and deleted. A file is
teamai's only as a team file now, today's or one from the history, as on
every other docs path that deletes or replaces (prune, copy, uninstall).

* test(hooks): check a Codex dispatcher entry outlasts its slowest team hook (#915)

Codex stops the dispatcher entry at its own timeout, so it has to cover
every team hook it runs, counting Codex's 600 s default for a hook that
names none.

* fix(mcp): count another tool's servers in a shared file even when it is not detected (#993)

Whose entries a config file holds was judged only with the tools detected on
this run. With `~/.codebuddy/mcp.json` linked to Claude's `~/.claude.json` and
Claude no longer installed, a CodeBuddy pull took Claude's identically rendered
server for its own and deleted it through the link, leaving Claude's record
stale. Every tool that maps a file counts now, detected or not: detection
decides where teamai writes, never what it may delete. Doctor judges the same way.

* fix(mcp): restore a file two tools write through linked paths once, to its first state (#993)

Configs written during an MCP sync are restored when ownership cannot be
saved. Snapshots were keyed by path, so a file two tools reach through
linked paths got one per path and the later one, taken after the first
tool's write, was restored last: the server stayed without its record. One
snapshot per real file now, taken before its first write.

* fix(coauthor): leave a settings file that does not parse as it is (#993)

Project scope writes the co-author setting to `.claude/settings.local.json`,
the member's own file. One that did not parse was read as empty, so a pull
replaced the whole file with teamai's `attribution` block and the member's
settings were lost. A Claude or Cursor file that is not a JSON object is now
left untouched and reported as skipped, and the setting is written on the
first pull after the member repairs it.

* fix(hooks): leave a hook settings file that does not parse as it is (#993)

Every hook writer read its settings file as empty when it did not parse, so
a pull or a session-start pass while the member was mid-edit of, say, their
HOME `~/.claude/settings.json` replaced the whole file with teamai's hooks.
The Claude, Cursor, Copilot, Codex and ZCode writers, and the agent-hook
install and removal, now leave such a file byte-identical and say which file
and why; nothing is recorded, and the first pull after the repair writes it.

* fix(docs): never walk a docs mirror that is a link of the member's (#993)

When `sharing.docs.localDir` itself was a link to a personal directory,
uninstall walked it and deleted the files behind it that matched a team doc
from the history, outside the mirror. A mirror root that is a link is now the
member's on every path: pull copies, prunes and withdraws nothing through it
and names it, doctor reports it, and uninstall leaves it and what it points at.

* fix(uninstall): keep the record of hooks a settings file that does not parse still holds (#993)

Uninstall left out a hook settings file that did not parse, then deleted the
data directory holding the record of its hooks; once the member repaired the
file, teamai's hooks ran there with no record left to remove them by. Such a
file is now tried, named and left as it is, and the data directory stays, so
uninstall run again after the repair removes them. `source remove-http` and
uninstall keep the record of an HTTP agent hook they could not remove, and the
local agent's directory with it, instead of clearing the whole manifest.

Pull also names a docs mirror that is a link before counting or previewing
its files, so it never walks it, on a dry run either.

* fix(uninstall): report an uninstall that left hooks in place as incomplete (#993)

When a hook settings file could not be cleaned, uninstall kept the data
directory but still said "teamai uninstalled" and exited 0, and a targeted
uninstall of the last tool did not record the tool as excluded, so the hook
left in place could sync its resources back. Hooks left in place now make the
uninstall incomplete, as an OpenCode entry it could not remove does: exit code
1, the files named, and the tool excluded. The message for a settings file that
does not parse no longer points at `teamai pull`.

* fix(uninstall): stop hooks an incomplete uninstall left in place from syncing back (#993)

A full uninstall that could not remove a tool's hooks kept the data directory
with every tool still enabled, so the hook left in place ran a session-start
pull that restored what had just been removed. Each tool whose hooks remain is
now excluded, and the session-start pull of an excluded tool's hook does
nothing, as its instruction and HTTP handlers already did, until the uninstall
is run again after the repair.

* fix(local-agent): keep removed HTTP sources disabled (#993)

* fix(local-agent): serialize HTTP sync and source removal (#993)

* docs(http): describe skipped concurrent hook syncs (#993)

* fix(mcp): take teamai's Claude local-scope servers out of every recorded key on uninstall and flag off (#915)

Uninstall and a pull with sharing.gitExclude off took teamai's servers out
of the current checkout's key only. A key no checkout has any more, as a
removed linked worktree of a --separate-git-dir repository leaves, kept
them, with their resolved values, for good. Both now clean every key
recorded in managed-local-mcp.json; a server the member changed stays and
is named, except on uninstall.

* feat(mcp): give CodeBuddy the team's project MCP servers in its local scope (#915)

With sharing.gitExclude on, CodeBuddy's project servers go to
${CODEBUDDY_CONFIG_DIR:-~}/.codebuddy.json under
projects[<realpath of the worktree root>].mcpServers, the key CodeBuddy
uses for the directory it runs in, instead of the project's .mcp.json.

- Each checkout writes its own key, on pull and on the pull git runs when
  it creates a worktree; records live with Claude's local-scope records,
  keyed by tool and key.
- The next pull takes teamai's servers out of the key of a worktree that is
  no longer a checkout of the repository, keeping the member's own and a
  copy they changed (named).
- Migration and flag-off follow Claude's: teamai's servers leave .mcp.json
  (recorded or proven by the team's history), the file goes when only
  they made it, and turning the option off moves them back.
- Uninstall removes exactly teamai's servers from every checkout's key.

* fix(opencode): on V2, take teamai's V1 entries out of a file that also holds the member's (#915)

On OpenCode V2 with teamai's plugin, a pull left the root opencode.json and
.opencode/opencode.json as they were once they held anything of the
member's, so V2 loaded teamai's servers twice. It now takes teamai's own
servers (recorded, or adopted by the per-name judgement) and its
instructions entries (equal to the ones it writes) out of a file git does
not track, and keeps the member's. A file git tracks is still never
modified, and doctor still names it.

* fix(uninstall): remove the HTTP source under its lock before teardown (#993)

A full user-scope uninstall removed agent hooks, plugins and ~/.teamai
without the local-agent lock, so a hook sync paused after loading its
config could reinstall resources once uninstall reported success.
Uninstall now runs the remove-http shutdown first: it waits up to 30
seconds for the lock, disables the source before teardown, and exits 1
without removing anything when the lock cannot be taken.

* fix(local-agent): let a server-pushed uninstall hold its sync's lock (#993)

A sync holds the HTTP source lock while it runs an uninstall_teamai
command, so the uninstall it spawns waited for that lock until the hook's
15-second budget killed the sync, unacknowledged and with teamai still
installed. The sync now passes its pid to the command; a child whose
parent holds the lock runs under it without acquiring or releasing it.

* test: stop hooks-wrapper from writing src/index.js, which shadowed the CLI entry (#915)

hooks-wrapper.test.ts wrote a stub src/index.js for the length of each
test so resolveTeamaiEntryScript() would find an entry. While it existed,
any test file running in parallel that imported '../index.js' resolved to
the stub instead of src/index.ts and got an empty module, so `program`
was undefined: a different command-table test failed on each loaded run.

The test now reports src/index.js as present through an existsSync spy
and writes nothing into the source tree. Tests that read the command
table load it through helpers/command-table.ts, which fails with the
reason when the entry yields no table instead of a TypeError.

* refactor(mcp): judge local-scope claims on the reconcile's claim targets (#915)

Leaving a tool's other MCP location re-resolved every tool's ta…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] keep project MCP configs with resolved tokens out of git

2 participants